From fa491ad643baacd44b828f5da2fc401bf098334a Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 3 Oct 2026 22:07:09 -0700 Subject: [PATCH] feat: add picture context menus and clipboard formats Add Cut, Copy, Copy Text from Picture, Save As, and Restore to Original Size to picture menus. Offer PNG beside the lossless clip on desktop and web, plus DIB for Windows readers. Normalize floating pictures pasted into outlines and follow images in newly created outlines so they save and draw immediately. Cover clipboard alpha, picture paste, and resize/cut undo. Refs #80 Assisted-by: gpt-6.1-sol --- crates/canvas/src/editor.rs | 17 +-- crates/canvas/src/editor/clip.rs | 31 +++-- crates/canvas/src/gpu/page.rs | 52 +++++---- crates/canvas/src/interaction/mod.rs | 72 +++++++++--- crates/canvas/src/interaction/tests.rs | 144 ++++++++++++++++++++++++ crates/snowbound/src/clipboard_linux.rs | 51 +++++++-- crates/snowbound/src/commands.rs | 8 +- crates/snowbound/src/link.rs | 94 +++++++++++++++- crates/snowbound/src/macos.rs | 26 ++++- crates/snowbound/src/main.rs | 2 +- crates/snowbound/src/paste.rs | 80 +++++++++++-- crates/snowbound/src/web.rs | 11 +- crates/snowbound/src/windows.rs | 57 +++++++--- crates/snowbound/web/glue.js | 5 + 14 files changed, 561 insertions(+), 89 deletions(-) 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 }); -- 2.54.0