diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index e960efa461ca364cef3ec9527e6d6de527e3cfed..32a0a418f5b07af5fe891799ac6d889274ef842e 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -2069,14 +2069,15 @@ impl CanvasEditor { /// The address picture `id` links to, in an outline or on the page. pub(crate) fn picture_link(&self, id: ExGuid) -> Option<&str> { - let image = match self.image(id) { - Some(image) => image, - None => match &self.outline_picture(id)?.3.content { - ParagraphContent::Image(image) => image, - _ => return None, - }, - }; - image.link.as_deref() + self.picture(id)?.link.as_deref() + } + + pub(crate) fn picture(&self, id: ExGuid) -> Option<&onestore::page::Image> { + self.image(id) + .or_else(|| match &self.outline_picture(id)?.3.content { + ParagraphContent::Image(image) => Some(image), + _ => None, + }) } /// The file a paragraph holds or the page shows, which the host opens and saves. diff --git a/crates/canvas/src/editor/clip.rs b/crates/canvas/src/editor/clip.rs index 5be34c5aa26cc34c565eaa13bcaf45e8db52d016..230d3a65b78adb9d84d2e263c056a44af5a77f37 100644 --- a/crates/canvas/src/editor/clip.rs +++ b/crates/canvas/src/editor/clip.rs @@ -56,6 +56,22 @@ impl Clip { } impl CanvasEditor { + pub(crate) fn clip_picture(&self, id: ExGuid) -> Result, EditError> { + let Some(image) = self.picture(id).filter(|image| image.bytes.is_some()) else { + return Ok(None); + }; + let mut node = crate::document::node( + Paragraph::new(String::new(), Default::default()), + Default::default(), + )?; + let mut image = image.clone(); + image.layout.x = None; + image.layout.y = None; + image.background = false; + node.content = ParagraphContent::Image(image); + Ok(Some(Clip::new(vec![node], &self.definitions))) + } + /// What Copy takes: the selection's paragraphs with the first and last cut to it, or the /// page selection's outlines top to bottom with an empty paragraph between, as OneNote /// 2010 copies them. None where nothing shows. @@ -156,17 +172,10 @@ impl CanvasEditor { let added = leaves(&nodes, None).count(); edit.replacement.splice(1..=count, nodes); let head = drop_empty_halves(&mut edit, count, ends_in_text); - let paragraph = start.paragraph + added - usize::from(head); - let caret = if ends_in_text { - TextPosition { - paragraph, - offset: last, - } - } else { - TextPosition { - paragraph: paragraph + 1, - offset: 0, - } + let paragraph = start.paragraph + added + usize::from(!ends_in_text) - usize::from(head); + let caret = TextPosition { + paragraph, + offset: last, }; self.commit(engine, edit, [caret; 2].into()) } diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 316210aedf4f61634cf86b4071aa2a5da5d4979f..c87a393bb84ffe65deef26fce6fc5cb3838e15d1 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -316,8 +316,7 @@ impl PageScene { Ok(()) } - /// Follows `editor` after an edit or undo brought page-level pictures the scene has not - /// seen, as `refresh` does. + /// Follows pictures and file previews brought by an edit or undo. pub(crate) fn follow( &mut self, editor: &mut CanvasEditor, @@ -332,7 +331,20 @@ impl PageScene { } _ => true, }; - if editor.objects.iter().all(known) { + let outlines_known = editor.outlines().iter().all(|outline| { + crate::document::descendants(outline.document().nodes(), None).all(|(_, _, node)| { + match &node.content { + onestore::page::ParagraphContent::Image(image) => { + self.pictures.contains_key(&image.id) + } + onestore::page::ParagraphContent::Attachment(file) => { + file.preview.is_none() || self.pictures.contains_key(&file.id) + } + _ => true, + } + }) + }); + if editor.objects.iter().all(known) && outlines_known { return Ok(()); } self.refresh(editor, engine) @@ -400,14 +412,15 @@ impl PageScene { payloads.push((source.id, [source.preview.as_ref(), None])) } Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads), - Content::Editable(id) => { - if let Some(outline) = - editor.and_then(|editor| editor.outlines().iter().find(|o| o.id == *id)) - { - nested(outline.document().nodes(), &mut payloads); - } - } - Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {} + Content::Editable(_) + | Content::Date { .. } + | Content::Ink(_) + | Content::ReadOnly(_) => {} + } + } + if let Some(editor) = editor { + for outline in editor.outlines() { + nested(outline.document().nodes(), &mut payloads); } } let mut pictures = std::collections::BTreeMap::new(); @@ -515,14 +528,15 @@ impl PageScene { outline(layout, origin, &mut rects); } } - Content::Editable(id) => { - if let Some(editable) = - editor.and_then(|editor| editor.visible_outlines().find(|o| o.id == *id)) - { - outline(editable.shaped(), editable.origin(), &mut rects); - } - } - Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {} + Content::Editable(_) + | Content::Date { .. } + | Content::Ink(_) + | Content::ReadOnly(_) => {} + } + } + if let Some(editor) = editor { + for editable in editor.visible_outlines() { + outline(editable.shaped(), editable.origin(), &mut rects); } } rects diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index f522e8be9fca54f3151fdd308f0175b5d4d32f71..5fb44c1842e00ee50469192b25294081135ba1cf 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -79,12 +79,13 @@ pub struct Context { /// The address of the link at the caret. pub link: Option, pub equation: bool, - /// Whether text is selected, which Cut and Copy take. + /// Whether text or a picture is selected, which Cut and Copy take. pub selected: bool, /// The paragraph at the caret, which Copy Link to Paragraph names. pub paragraph: Option, /// The file the press selected, which Open and Save As take. pub attachment: Option, + pub image: Option, /// The marked word the press landed on, which the menu offers corrections for. pub spelling: Option, } @@ -640,9 +641,12 @@ impl PageView { /// Page coordinates of a focused object. fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] { - let (scene, offset) = self.scene.as_ref().unwrap(); let rect = match focus { - ObjectFocus::ReadOnly(index) => scene + ObjectFocus::ReadOnly(index) => self + .scene + .as_ref() + .unwrap() + .0 .read_only(Some(&self.editor)) .nth(index) .unwrap() @@ -653,7 +657,10 @@ impl PageView { } ObjectFocus::File(id) => self.editor.attachment_rect(id).unwrap(), }; - crate::translated(rect, *offset) + crate::translated( + rect, + self.scene.as_ref().map_or([0.0; 2], |(_, offset)| *offset), + ) } /// Keeps the view in bounds and restarts the caret blink after a change. @@ -1570,6 +1577,22 @@ impl PageView { /// Copy, or Cut with `cut`: [`CanvasEditor::clip`] goes to the clipboard. pub fn copy(&mut self, cut: bool) -> Result { + if let Some(ObjectFocus::Image(id)) = self.object_focus { + let Some(clip) = self.editor.clip_picture(id)? else { + return Ok(Response::default()); + }; + if cut { + self.editor.remove_image(&mut self.engine, id)?; + self.set_object_focus(None); + } + return Ok(Response { + request: Some(Request::Copy(clip)), + ..self.changed()? + }); + } + if self.object_focus.is_some() { + return Ok(Response::default()); + } let page = self.editor.whole() == Some(Whole::Page); let Some(clip) = self.editor.clip()? else { return Ok(Response::default()); @@ -1585,20 +1608,33 @@ impl PageView { }) } + pub fn restore_picture_size(&mut self, id: onestore::ExGuid) -> Result { + let Some(size) = self.editor.picture(id).and_then(|image| image.size) else { + return Ok(Response::default()); + }; + let Some((origin, _)) = self.editor.image_placement(id) else { + return Ok(Response::default()); + }; + self.editor + .place_image(&mut self.engine, id, origin, size)?; + self.edited() + } + /// A secondary press at the pointer: text there takes the caret unless it lies in the - /// selection, and a file is selected, as OneNote's context menu acts where it opens. - /// `None` off text and files. + /// selection, and a picture or file is selected. + /// `None` off text, pictures and files. pub fn context(&mut self) -> Result> { let point = self.viewport.document_point(self.pointer); let (id, point) = match self.hit_test(point) { Some(Hit::Text { id, point }) => (id, point), - Some(Hit::File(id)) => { - let attachment = self.editor.attachment(id).cloned(); - self.set_object_focus(Some(ObjectFocus::File(id))); - let context = Context { - attachment, - ..Context::default() + Some(Hit::File(id)) | Some(Hit::Image { id, .. }) => { + let focus = if self.editor.attachment(id).is_some() { + ObjectFocus::File(id) + } else { + ObjectFocus::Image(id) }; + self.set_object_focus(Some(focus)); + let context = self.caret_context().unwrap_or_default(); return Ok(Some((self.changed()?, context))); } _ => return Ok(None), @@ -1622,9 +1658,16 @@ impl PageView { Ok(Some((self.changed()?, context))) } - /// What a context menu at the caret or the selected file acts on; `None` where neither + /// What a context menu at the caret or the selected object acts on; `None` where neither /// takes one. pub fn caret_context(&self) -> Option { + if let Some(ObjectFocus::Image(id)) = self.object_focus { + return Some(Context { + image: self.editor.picture(id).cloned(), + selected: true, + ..Context::default() + }); + } if let Some(ObjectFocus::File(id)) = self.object_focus { return Some(Context { attachment: self.editor.attachment(id).cloned(), @@ -1649,6 +1692,7 @@ impl PageView { .leaf(anchor.min(focus).paragraph) .map(|(_, _, node)| node.id), attachment: None, + image: None, spelling: self.selected_correction(), }) } @@ -1991,7 +2035,7 @@ impl PageView { self.changed() } - /// Draws page-level pictures an edit or undo brought to the page. + /// Draws pictures and file previews brought by an edit or undo. fn follow_pictures(&mut self) -> Result<()> { if let Some((scene, _)) = &mut self.scene { scene.follow(&mut self.editor, &mut self.engine)?; diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index d6e8ec6d53d36a0c17e26058f25f0d283f43829a..417e81cf0c9d9a51b281048424919a1f9b3a7e5d 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -1593,6 +1593,150 @@ fn picture_view() -> (PageView, onestore::ExGuid) { (view, id) } +#[test] +fn a_picture_context_copies_and_cuts_the_picture_instead_of_hidden_text() { + use onestore::page::{PageObject, ParagraphContent}; + for floating in [false, true] { + let (mut view, _) = picture_view(); + let bytes = include_bytes!("../../../../corpus/object-tags/native/notebook/photo.png"); + let id = if floating { + let _ = view + .drop_picture([400.0, 200.0], bytes.to_vec(), [40.0, 30.0]) + .unwrap(); + view.editor + .page() + .unwrap() + .objects + .iter() + .find_map(|object| match object { + PageObject::Image(image) => Some(image.id), + _ => None, + }) + .unwrap() + } else { + let image = crate::editor::picture(bytes.to_vec(), [40.0, 30.0]).unwrap(); + let id = image.id; + view.editor.insert_picture(&mut view.engine, image).unwrap(); + id + }; + let (scene, _) = + PageScene::from_page(view.editor.page().unwrap(), &mut view.engine).unwrap(); + view.scene = Some((scene, [0.0; 2])); + let (origin, size) = view.editor.image_placement(id).unwrap(); + let _ = view + .pointer_moved([origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0]) + .unwrap(); + let (_, context) = view.context().unwrap().unwrap(); + assert_eq!(context.image.as_ref().unwrap().id, id); + assert!(context.selected); + let copied = view.copy(false).unwrap(); + let Some(Request::Copy(clip)) = copied.request else { + panic!("a picture copy"); + }; + assert_eq!(clip.paragraphs.len(), 1); + let ParagraphContent::Image(picture) = &clip.paragraphs[0].content else { + panic!("a picture"); + }; + assert_eq!(picture.id, id); + assert_eq!([picture.layout.x, picture.layout.y], [None; 2]); + assert_eq!(picture.bytes.as_deref(), Some(bytes.as_slice())); + assert_eq!(clip.text(), ""); + assert!(view.editor.image_placement(id).is_some()); + let cut = view.copy(true).unwrap(); + assert!(cut.changed); + assert!(matches!(cut.request, Some(Request::Copy(_)))); + assert!(view.editor.image_placement(id).is_none()); + assert!(view.undo(false).unwrap().changed); + assert!(view.editor.image_placement(id).is_some()); + assert!(view.undo(true).unwrap().changed); + assert!(view.editor.image_placement(id).is_none()); + view.editor + .place_caret(&mut view.engine, [600.0, 400.0], 240.0) + .unwrap(); + assert!(view.paste_clip(clip).unwrap().changed); + let page = view.editor.page().unwrap(); + assert!( + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Outline(outline) => Some(outline), + _ => None, + }) + .flat_map(|outline| &outline.paragraphs) + .filter_map(|node| node.text()) + .any(|text| text.text.text() == "Before") + ); + let pasted = view + .editor + .outlines() + .iter() + .flat_map(|outline| crate::document::descendants(outline.document().nodes(), None)) + .find_map(|(_, _, node)| match &node.content { + ParagraphContent::Image(image) if image.bytes.is_some() => Some(image.id), + _ => None, + }) + .unwrap(); + let (scene, _) = view.scene.as_mut().unwrap(); + scene.settle(Some(&view.editor), 1.0, crate::gpu::Paper::WHITE); + assert!(scene.image(pasted).is_some()); + } +} + +#[test] +fn restoring_a_pictures_original_size_is_undoable_in_an_outline_and_on_the_page() { + for floating in [false, true] { + let (mut view, _) = picture_view(); + let bytes = include_bytes!("../../../../corpus/object-tags/native/notebook/photo.png"); + let id = if floating { + let _ = view + .drop_picture([400.0, 200.0], bytes.to_vec(), [40.0, 30.0]) + .unwrap(); + view.editor + .page() + .unwrap() + .objects + .iter() + .find_map(|object| match object { + onestore::page::PageObject::Image(image) => Some(image.id), + _ => None, + }) + .unwrap() + } else { + let image = crate::editor::picture(bytes.to_vec(), [40.0, 30.0]).unwrap(); + let id = image.id; + view.editor.insert_picture(&mut view.engine, image).unwrap(); + id + }; + let (scene, _) = + PageScene::from_page(view.editor.page().unwrap(), &mut view.engine).unwrap(); + view.scene = Some((scene, [0.0; 2])); + let (origin, _) = view.editor.image_placement(id).unwrap(); + view.editor + .place_image(&mut view.engine, id, origin, [80.0, 60.0]) + .unwrap(); + let _ = view + .pointer_moved([origin[0] + 10.0, origin[1] + 10.0]) + .unwrap(); + let (_, context) = view.context().unwrap().unwrap(); + assert_eq!(context.image.unwrap().id, id); + assert!(view.restore_picture_size(id).unwrap().changed); + assert_eq!( + view.editor.image_placement(id), + Some((origin, [40.0, 30.0])) + ); + assert!(view.undo(false).unwrap().changed); + assert_eq!( + view.editor.image_placement(id), + Some((origin, [80.0, 60.0])) + ); + assert!(view.undo(true).unwrap().changed); + assert_eq!( + view.editor.image_placement(id), + Some((origin, [40.0, 30.0])) + ); + } +} + #[test] fn a_page_outline_emptied_by_backspace_still_draws() { let mut engine = TextEngine::default(); diff --git a/crates/snowbound/src/clipboard_linux.rs b/crates/snowbound/src/clipboard_linux.rs index 59436b82f20801d269ff56a40b4cbcd4a929cb67..8d5dde440cd791b385b156ce9e9f03ecb2fc8169 100644 --- a/crates/snowbound/src/clipboard_linux.rs +++ b/crates/snowbound/src/clipboard_linux.rs @@ -47,8 +47,12 @@ const CLIP: &str = "application/x-snowbound-clip"; /// How long a paste waits on the app that copied. const PATIENCE: Duration = Duration::from_secs(2); -/// The HTML and Snowbound's own format of this process's last copy through X11. -static LAST_COPY: Mutex> = Mutex::new(None); +enum CopyFormat { + Html(String), + Picture(arboard::ImageData<'static>), +} + +static LAST_COPY: Mutex> = Mutex::new(None); pub enum Clipboard { Wayland(Box), @@ -82,28 +86,61 @@ impl Clipboard { pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), Box> { match self { Self::Wayland(wayland) => { + if let Some(picture) = &copied.picture { + return wayland.copy(vec![ + ("image/png", picture.clone()), + (CLIP, copied.clip.clone().into_bytes()), + ]); + } let text = TEXT.map(|kind| (kind, copied.text.clone().into_bytes())); let rich = [("text/html", &copied.html), (CLIP, &copied.clip)] .map(|(kind, bytes)| (kind, bytes.clone().into_bytes())); wayland.copy(text.into_iter().chain(rich).collect()) } Self::X11(clipboard) => { + if let Some(picture) = &copied.picture { + let ([width, height], bytes) = crate::paste::pixels(picture)?; + let image = arboard::ImageData { + width: width as usize, + height: height as usize, + bytes: bytes.into(), + }; + clipboard.set_image(image.clone())?; + *LAST_COPY.lock().unwrap() = + Some((CopyFormat::Picture(image), copied.clip.clone())); + return Ok(()); + } clipboard.set().html(&copied.html, Some(&copied.text))?; - *LAST_COPY.lock().unwrap() = Some((copied.html.clone(), copied.clip.clone())); + *LAST_COPY.lock().unwrap() = + Some((CopyFormat::Html(copied.html.clone()), copied.clip.clone())); Ok(()) } } } - /// What Snowbound itself copied, if it did: on X11, which arboard offers no format of - /// Snowbound's own through, the last copy's, while the clipboard holds its HTML. + /// On X11, the last clip while the clipboard still holds its HTML or pixels. pub fn get_clip(&mut self) -> Option { if let Self::Wayland(wayland) = self { return String::from_utf8(wayland.paste(CLIP)?).ok(); } - let html = self.get_html()?; + let Self::X11(clipboard) = self else { + return None; + }; match &*LAST_COPY.lock().unwrap() { - Some((copied, clip)) if *copied == html => Some(clip.clone()), + Some((CopyFormat::Html(copied), clip)) + if clipboard.get().html().ok().as_ref() == Some(copied) => + { + Some(clip.clone()) + } + Some((CopyFormat::Picture(copied), clip)) => clipboard + .get_image() + .ok() + .filter(|image| { + image.width == copied.width + && image.height == copied.height + && image.bytes == copied.bytes + }) + .map(|_| clip.clone()), _ => None, } } diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index fe6547df3af6538e831c088b0bc7735cc0a26b52..d4c64ba29cef5b8cc217262f4632412aca435b90 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -1122,7 +1122,13 @@ impl State { // Edit commands act on a focused field instead of the page. let field = self.ui.focused_field().is_some(); let [anchor, focus] = self.view.editor.selection().positions; - let selected = page && !field && typing && anchor != focus; + let selected = page + && !field + && (typing && anchor != focus + || matches!( + self.view.object_focus(), + Some(canvas::interaction::ObjectFocus::Image(_)) + )); let enabled = |enabled| Status { enabled, checked: None, diff --git a/crates/snowbound/src/link.rs b/crates/snowbound/src/link.rs index 513f84e3b5e333012f39be6869c624597c809baf..7d0067e8eb349a110e6440ad2e38f5689552e376 100644 --- a/crates/snowbound/src/link.rs +++ b/crates/snowbound/src/link.rs @@ -250,7 +250,7 @@ impl State { Ok(()) } - /// A secondary press on the page's text or a file opens its context menu there. + /// A secondary press on text, a picture or a file opens its context menu there. pub(crate) fn open_text_menu(&mut self) -> Result<(), Box> { let Some((response, context)) = self.view.context()? else { return Ok(()); @@ -263,7 +263,7 @@ impl State { } /// The page's context menu while it is open, with OneNote 2010's commands for text, - /// links, equations and files. + /// links, equations, pictures and files. pub(crate) fn text_menu(&mut self) -> Result<(), Box> { let Some((context, point)) = &self.text_menu else { return Ok(()); @@ -371,6 +371,53 @@ impl State { }; let format = self.format_state(); let disabled = |id| !self.status(&commands::Choice::Command(id), &format).enabled; + if let Some(image) = &context.image { + return vec![ + ( + Item { + disabled: disabled(commands::Id::Cut) || image.bytes.is_none(), + ..drawn("Cut", art::CUT) + }, + "Cut", + ), + ( + Item { + disabled: image.bytes.is_none(), + ..drawn("Copy", art::COPY) + }, + "Copy", + ), + ( + Item { + disabled: image.text.as_ref().is_none_or(|text| text.text.is_empty()), + ..drawn("Copy Text from Picture", art::COPY) + }, + "Copy Text from Picture", + ), + ( + Item { + separated: true, + disabled: image.bytes.is_none(), + ..drawn("Save As", art::SAVE) + }, + "Save Picture As", + ), + ( + Item { + separated: true, + disabled: disabled(commands::Id::Cut) + || image.size.is_none_or(|size| { + self.view + .editor + .image_placement(image.id) + .is_none_or(|(_, shown)| shown == size) + }), + ..drawn("Restore to Original Size", art::PICTURE) + }, + "Restore to Original Size", + ), + ]; + } // Paste stands for any edit to the text here. let (fixed, unlinkable) = (disabled(commands::Id::Paste), disabled(commands::Id::Link)); if context.attachment.is_some() { @@ -471,6 +518,49 @@ impl State { context: Context, text: &str, ) -> Result<(), Box> { + if let Some(image) = &context.image { + match text { + "Save As" => { + if let Some(bytes) = &image.bytes { + let name = if bytes.starts_with(b"\xff\xd8") { + "Picture.jpg" + } else if bytes.starts_with(b"GIF8") { + "Picture.gif" + } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + "Picture.png" + } else { + "Picture" + }; + #[cfg(target_arch = "wasm32")] + platform::download(name, bytes, "application/octet-stream"); + #[cfg(not(target_arch = "wasm32"))] + platform::pick_new( + "Save As", + name, + "Save", + None, + self.reply({ + let bytes = bytes.clone(); + move |_, path| Ok(notebook::fs::write(path, bytes)?) + }), + ); + } + return Ok(()); + } + "Copy Text from Picture" => { + if let Some(text) = &image.text { + self.clipboard.set_text(text.text.clone())?; + } + return Ok(()); + } + "Restore to Original Size" => { + let response = self.view.restore_picture_size(image.id)?; + self.respond(response); + return Ok(()); + } + _ => {} + } + } if let Some(file) = &context.attachment { return match text { "Open" => self.open_attachment(file), diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 0be2690ecf7452fcf6a97ded65370997bb9f4a0a..6deb96587dd0bf8ace7549219876f33a2452f69f 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -260,6 +260,17 @@ impl Clipboard { pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), &'static str> { let set = unsafe { let _: isize = msg_send![&self.0, clearContents]; + if let Some(picture) = &copied.picture { + let data: Retained = msg_send_id![class!(NSData), dataWithBytes: picture.as_ptr().cast::(), length: picture.len()]; + let kind = NSString::from_str("public.png"); + let set: bool = msg_send![&self.0, setData: &*data, forType: &*kind]; + let clip = NSString::from_str(&copied.clip); + let kind = NSString::from_str(CLIP); + let own: bool = msg_send![&self.0, setString: &*clip, forType: &*kind]; + return (set && own) + .then_some(()) + .ok_or("The pasteboard refused the picture"); + } [ (TEXT, &copied.text), ("public.html", &copied.html), @@ -368,8 +379,8 @@ unsafe fn ns_data_bytes(data: &AnyObject) -> Vec { if length == 0 { return Vec::new(); } - let bytes: *const u8 = msg_send![data, bytes]; - std::slice::from_raw_parts(bytes, length).to_vec() + let bytes: *const std::ffi::c_void = msg_send![data, bytes]; + std::slice::from_raw_parts(bytes.cast::(), length).to_vec() } } @@ -1748,12 +1759,23 @@ mod tests { let copied = crate::paste::Copied { text: "Copied".into(), html: "

Copied

".into(), + picture: None, clip: "{}".into(), }; clipboard.set(&copied).unwrap(); assert_eq!(clipboard.get_text().unwrap(), "Copied"); assert_eq!(clipboard.get_html().as_deref(), Some("

Copied

")); assert_eq!(clipboard.get_clip().as_deref(), Some("{}")); + let picture = include_bytes!("../../../corpus/object-tags/native/notebook/photo.png"); + let copied = crate::paste::Copied { + picture: Some(picture.to_vec()), + ..copied + }; + clipboard.set(&copied).unwrap(); + assert_eq!(clipboard.get_picture().as_deref(), Some(picture.as_slice())); + assert_eq!(clipboard.get_clip().as_deref(), Some("{}")); + assert_eq!(clipboard.get_html(), None); + assert!(clipboard.get_text().is_err()); clipboard.set_text("Plain".into()).unwrap(); assert_eq!(clipboard.get_clip(), None); unsafe { msg_send![&board, releaseGlobally] } diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index edde70e506d0d12c4f35f726697f54fbd094efb7..8e1f159a5e2cf1f171726609092b7226712e8d64 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -348,7 +348,7 @@ impl Clipboard { fn set(&mut self, clip: Clip) -> Result<(), Box> { match self { - Self::System(clipboard) => clipboard.set(&paste::Copied::new(&clip))?, + Self::System(clipboard) => clipboard.set(&paste::Copied::new(&clip)?)?, Self::Memory(held, kept) => (*held, *kept) = (clip.text(), Some(clip)), } Ok(()) diff --git a/crates/snowbound/src/paste.rs b/crates/snowbound/src/paste.rs index 03e30071811dcc6bb52a2a3bf0ff931dbb6f2eed..ca1da6c330c9981b307f812d987930fee18fb0ea 100644 --- a/crates/snowbound/src/paste.rs +++ b/crates/snowbound/src/paste.rs @@ -8,26 +8,85 @@ use std::{ path::{Path, PathBuf}, }; -/// Every format Copy puts on the clipboard for one selection, which each platform's clipboard -/// offers together: text; HTML, as OneNote 2010 offers it beside text (lab, 2026-10-02); and -/// Snowbound's own format, [`Clip::encode`]'s JSON, which pastes losslessly into Snowbound. -/// Its name is `net.paperclover.snowbound.clip` on macOS, `Snowbound Clip` on Windows and -/// `application/x-snowbound-clip` where formats are MIME types. +/// Copy offers text and HTML, or a picture as PNG, beside Snowbound's lossless clip. pub(crate) struct Copied { pub text: String, /// A whole page, the copy between `` and ``. pub html: String, pub clip: String, + pub picture: Option>, } impl Copied { - pub(crate) fn new(clip: &Clip) -> Self { - Self { + pub(crate) fn new(clip: &Clip) -> Result> { + let picture = match clip.paragraphs.as_slice() { + [ + onestore::page::PageParagraph { + content: onestore::page::ParagraphContent::Image(image), + .. + }, + ] => image.display.as_deref().or(image.bytes.as_deref()), + _ => None, + }; + let picture = picture + .map(|bytes| -> Result, Box> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Ok(bytes.to_vec()) + } else { + let (size, rgba) = pixels(bytes)?; + Ok(png(size, &rgba)?) + } + }) + .transpose()?; + Ok(Self { text: clip.text(), html: clip.html(), clip: clip.encode(), + picture, + }) + } +} + +pub(crate) fn pixels(encoded: &[u8]) -> Result<([u32; 2], Vec), Box> { + if !encoded.starts_with(b"\x89PNG\r\n\x1a\n") { + let image = draw::RasterImage::decode(encoded, [u32::MAX; 2]) + .map_err(|error| format!("{error:?}"))?; + let mut rgba = image.pixels().to_vec(); + for pixel in rgba.chunks_exact_mut(4) { + let alpha = u32::from(pixel[3]); + for channel in &mut pixel[..3] { + *channel = (u32::from(*channel) * 255 + alpha / 2) + .checked_div(alpha) + .unwrap_or(0) + .min(255) as u8; + } } + return Ok((image.size(), rgba)); } + let size = draw::RasterImage::measure(encoded).map_err(|error| format!("{error:?}"))?; + if u64::from(size[0]) * u64::from(size[1]) * 4 > draw::MAX_IMAGE_BYTES { + return Err("The picture is too large for the clipboard".into()); + } + let mut decoder = png::Decoder::new(std::io::Cursor::new(encoded)); + decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); + let mut reader = decoder.read_info()?; + let mut bytes = vec![ + 0; + reader + .output_buffer_size() + .ok_or("The picture is too large")? + ]; + let info = reader.next_frame(&mut bytes)?; + let rgba = bytes[..info.buffer_size()] + .chunks_exact(info.color_type.samples()) + .flat_map(|pixel| match info.color_type { + png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]], + png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255], + png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]], + _ => [pixel[0], pixel[0], pixel[0], 255], + }) + .collect(); + Ok(([info.width, info.height], rgba)) } /// `html` behind the header Windows' `HTML Format` begins with, which gives the byte offsets @@ -421,6 +480,13 @@ pub(crate) fn png(size: [u32; 2], rgba: &[u8]) -> Result, png::EncodingE mod tests { use super::*; + #[test] + fn clipboard_pixels_keep_straight_alpha() { + let rgba = [239, 17, 123, 37, 8, 49, 203, 0]; + let encoded = png([2, 1], &rgba).unwrap(); + assert_eq!(pixels(&encoded).unwrap(), ([2, 1], rgba.to_vec())); + } + fn png_at(pixels_per_metre: Option) -> Vec { let mut bytes = Vec::new(); let mut encoder = png::Encoder::new(&mut bytes, 200, 100); diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs index 2406947a2f16414592c8462dad99191f1e54c70c..5059f960b922776c8710d60f85b09c8dc31abb2a 100644 --- a/crates/snowbound/src/web.rs +++ b/crates/snowbound/src/web.rs @@ -44,6 +44,8 @@ extern "C" { fn place_input(x: f32, y: f32, height: f32, text: bool); #[wasm_bindgen(js_name = writeClipboard)] fn write_clipboard(text: &str); + #[wasm_bindgen(js_name = copyPicture)] + fn copy_picture(bytes: &[u8]); /// Hands `bytes` to the browser to save as `name`, of MIME type `kind`. pub fn download(name: &str, bytes: &[u8], kind: &str); #[wasm_bindgen(js_name = pickFiles)] @@ -523,9 +525,14 @@ impl Clipboard { Ok(()) } - /// A copy's text, the format every browser's clipboard takes. + /// Text or a PNG picture on the browser's clipboard. pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), Box> { - self.set_text(copied.text.clone()) + if let Some(picture) = &copied.picture { + copy_picture(picture); + Ok(()) + } else { + self.set_text(copied.text.clone()) + } } pub fn get_clip(&mut self) -> Option { diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs index 50d0e4dea5970ace578940324cb58f50c0b557bf..d038f3b5952cea21b49773eb5f82cdd361c62a2c 100644 --- a/crates/snowbound/src/windows.rs +++ b/crates/snowbound/src/windows.rs @@ -1112,22 +1112,49 @@ impl Clipboard { self.0.set_text(text) } - /// Text, `HTML Format` and Snowbound's own, as one copy. + /// Text and HTML, or a PNG and DIB, beside Snowbound's lossless clip. pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), &'static str> { - use windows_sys::Win32::System::{DataExchange as clip, Memory, Ole::CF_UNICODETEXT}; - let text: Vec = copied - .text - .replace('\n', "\r\n") - .encode_utf16() - .chain([0]) - .flat_map(u16::to_le_bytes) - .collect(); - let html = crate::paste::cf_html(&copied.html); - let formats = [ - (u32::from(CF_UNICODETEXT), text), - (registered("HTML Format"), [html.as_bytes(), &[0]].concat()), - (registered(CLIP), [copied.clip.as_bytes(), &[0]].concat()), - ]; + use windows_sys::Win32::System::{ + DataExchange as clip, Memory, + Ole::{CF_DIB, CF_UNICODETEXT}, + }; + let formats = if let Some(picture) = &copied.picture { + let (size, mut rgba) = + crate::paste::pixels(picture).map_err(|_| "The picture couldn't be decoded")?; + for pixel in rgba.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + let header = [ + 40_u32.to_le_bytes().as_slice(), + (size[0] as i32).to_le_bytes().as_slice(), + (-(size[1] as i32)).to_le_bytes().as_slice(), + 1_u16.to_le_bytes().as_slice(), + 32_u16.to_le_bytes().as_slice(), + 0_u32.to_le_bytes().as_slice(), + (rgba.len() as u32).to_le_bytes().as_slice(), + &[0; 16], + ] + .concat(); + vec![ + (u32::from(CF_DIB), [header, rgba].concat()), + (registered("PNG"), picture.clone()), + (registered(CLIP), [copied.clip.as_bytes(), &[0]].concat()), + ] + } else { + let text: Vec = copied + .text + .replace('\n', "\r\n") + .encode_utf16() + .chain([0]) + .flat_map(u16::to_le_bytes) + .collect(); + let html = crate::paste::cf_html(&copied.html); + vec![ + (u32::from(CF_UNICODETEXT), text), + (registered("HTML Format"), [html.as_bytes(), &[0]].concat()), + (registered(CLIP), [copied.clip.as_bytes(), &[0]].concat()), + ] + }; let _open = Open::new(self.1)?; if unsafe { clip::EmptyClipboard() } == 0 { return Err("The clipboard couldn't be emptied"); diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js index ffc726c22da8381c402eb080e9f5301ee21a5ffe..3eb7d41f542a210f5f63038fc0396809893a3696 100644 --- a/crates/snowbound/web/glue.js +++ b/crates/snowbound/web/glue.js @@ -443,6 +443,11 @@ export function writeClipboard(text) { navigator.clipboard.writeText(text).catch((error) => console.warn("Copy", error)); } +export function copyPicture(bytes) { + navigator.clipboard.write([new ClipboardItem({ "image/png": new Blob([bytes], { type: "image/png" }) })]) + .catch((error) => console.warn("Copy", error)); +} + export function download(name, bytes, kind) { const url = URL.createObjectURL(new Blob([bytes], { type: kind })); const link = Object.assign(document.createElement("a"), { href: url, download: name });