authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 22:07:09-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 22:37:36-07:00
logfa491ad643baacd44b828f5da2fc401bf098334a
tree0ade54ebeb9310e6f4adc2c8ce85ebfad6f76121
parente555f7cca8a01b01a44cb5a6689d4e8f7072491a
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

14 files changed, 561 insertions(+), 89 deletions(-)

crates/canvas/src/editor.rs+9-8
......@@ -2069,14 +2069,15 @@ impl CanvasEditor {
20692069
20702070 /// The address picture `id` links to, in an outline or on the page.
20712071 pub(crate) fn picture_link(&self, id: ExGuid) -> Option<&str> {
2072 let image = match self.image(id) {
2073 Some(image) => image,
2074 None => match &self.outline_picture(id)?.3.content {
2075 ParagraphContent::Image(image) => image,
2076 _ => return None,
2077 },
2078 };
2079 image.link.as_deref()
2072 self.picture(id)?.link.as_deref()
2073 }
2074
2075 pub(crate) fn picture(&self, id: ExGuid) -> Option<&onestore::page::Image> {
2076 self.image(id)
2077 .or_else(|| match &self.outline_picture(id)?.3.content {
2078 ParagraphContent::Image(image) => Some(image),
2079 _ => None,
2080 })
20802081 }
20812082
20822083 /// The file a paragraph holds or the page shows, which the host opens and saves.
crates/canvas/src/editor/clip.rs+20-11
......@@ -56,6 +56,22 @@ impl Clip {
5656}
5757
5858impl CanvasEditor {
59 pub(crate) fn clip_picture(&self, id: ExGuid) -> Result<Option<Clip>, EditError> {
60 let Some(image) = self.picture(id).filter(|image| image.bytes.is_some()) else {
61 return Ok(None);
62 };
63 let mut node = crate::document::node(
64 Paragraph::new(String::new(), Default::default()),
65 Default::default(),
66 )?;
67 let mut image = image.clone();
68 image.layout.x = None;
69 image.layout.y = None;
70 image.background = false;
71 node.content = ParagraphContent::Image(image);
72 Ok(Some(Clip::new(vec![node], &self.definitions)))
73 }
74
5975 /// What Copy takes: the selection's paragraphs with the first and last cut to it, or the
6076 /// page selection's outlines top to bottom with an empty paragraph between, as OneNote
6177 /// 2010 copies them. None where nothing shows.
......@@ -156,17 +172,10 @@ impl CanvasEditor {
156172 let added = leaves(&nodes, None).count();
157173 edit.replacement.splice(1..=count, nodes);
158174 let head = drop_empty_halves(&mut edit, count, ends_in_text);
159 let paragraph = start.paragraph + added - usize::from(head);
160 let caret = if ends_in_text {
161 TextPosition {
162 paragraph,
163 offset: last,
164 }
165 } else {
166 TextPosition {
167 paragraph: paragraph + 1,
168 offset: 0,
169 }
175 let paragraph = start.paragraph + added + usize::from(!ends_in_text) - usize::from(head);
176 let caret = TextPosition {
177 paragraph,
178 offset: last,
170179 };
171180 self.commit(engine, edit, [caret; 2].into())
172181 }
crates/canvas/src/gpu/page.rs+33-19
......@@ -316,8 +316,7 @@ impl PageScene {
316316 Ok(())
317317 }
318318
319 /// Follows `editor` after an edit or undo brought page-level pictures the scene has not
320 /// seen, as `refresh` does.
319 /// Follows pictures and file previews brought by an edit or undo.
321320 pub(crate) fn follow(
322321 &mut self,
323322 editor: &mut CanvasEditor,
......@@ -332,7 +331,20 @@ impl PageScene {
332331 }
333332 _ => true,
334333 };
335 if editor.objects.iter().all(known) {
334 let outlines_known = editor.outlines().iter().all(|outline| {
335 crate::document::descendants(outline.document().nodes(), None).all(|(_, _, node)| {
336 match &node.content {
337 onestore::page::ParagraphContent::Image(image) => {
338 self.pictures.contains_key(&image.id)
339 }
340 onestore::page::ParagraphContent::Attachment(file) => {
341 file.preview.is_none() || self.pictures.contains_key(&file.id)
342 }
343 _ => true,
344 }
345 })
346 });
347 if editor.objects.iter().all(known) && outlines_known {
336348 return Ok(());
337349 }
338350 self.refresh(editor, engine)
......@@ -400,14 +412,15 @@ impl PageScene {
400412 payloads.push((source.id, [source.preview.as_ref(), None]))
401413 }
402414 Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads),
403 Content::Editable(id) => {
404 if let Some(outline) =
405 editor.and_then(|editor| editor.outlines().iter().find(|o| o.id == *id))
406 {
407 nested(outline.document().nodes(), &mut payloads);
408 }
409 }
410 Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {}
415 Content::Editable(_)
416 | Content::Date { .. }
417 | Content::Ink(_)
418 | Content::ReadOnly(_) => {}
419 }
420 }
421 if let Some(editor) = editor {
422 for outline in editor.outlines() {
423 nested(outline.document().nodes(), &mut payloads);
411424 }
412425 }
413426 let mut pictures = std::collections::BTreeMap::new();
......@@ -515,14 +528,15 @@ impl PageScene {
515528 outline(layout, origin, &mut rects);
516529 }
517530 }
518 Content::Editable(id) => {
519 if let Some(editable) =
520 editor.and_then(|editor| editor.visible_outlines().find(|o| o.id == *id))
521 {
522 outline(editable.shaped(), editable.origin(), &mut rects);
523 }
524 }
525 Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {}
531 Content::Editable(_)
532 | Content::Date { .. }
533 | Content::Ink(_)
534 | Content::ReadOnly(_) => {}
535 }
536 }
537 if let Some(editor) = editor {
538 for editable in editor.visible_outlines() {
539 outline(editable.shaped(), editable.origin(), &mut rects);
526540 }
527541 }
528542 rects
crates/canvas/src/interaction/mod.rs+58-14
......@@ -79,12 +79,13 @@ pub struct Context {
7979 /// The address of the link at the caret.
8080 pub link: Option<String>,
8181 pub equation: bool,
82 /// Whether text is selected, which Cut and Copy take.
82 /// Whether text or a picture is selected, which Cut and Copy take.
8383 pub selected: bool,
8484 /// The paragraph at the caret, which Copy Link to Paragraph names.
8585 pub paragraph: Option<onestore::ExGuid>,
8686 /// The file the press selected, which Open and Save As take.
8787 pub attachment: Option<onestore::page::Attachment>,
88 pub image: Option<onestore::page::Image>,
8889 /// The marked word the press landed on, which the menu offers corrections for.
8990 pub spelling: Option<Correction>,
9091}
......@@ -640,9 +641,12 @@ impl PageView {
640641
641642 /// Page coordinates of a focused object.
642643 fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] {
643 let (scene, offset) = self.scene.as_ref().unwrap();
644644 let rect = match focus {
645 ObjectFocus::ReadOnly(index) => scene
645 ObjectFocus::ReadOnly(index) => self
646 .scene
647 .as_ref()
648 .unwrap()
649 .0
646650 .read_only(Some(&self.editor))
647651 .nth(index)
648652 .unwrap()
......@@ -653,7 +657,10 @@ impl PageView {
653657 }
654658 ObjectFocus::File(id) => self.editor.attachment_rect(id).unwrap(),
655659 };
656 crate::translated(rect, *offset)
660 crate::translated(
661 rect,
662 self.scene.as_ref().map_or([0.0; 2], |(_, offset)| *offset),
663 )
657664 }
658665
659666 /// Keeps the view in bounds and restarts the caret blink after a change.
......@@ -1570,6 +1577,22 @@ impl PageView {
15701577
15711578 /// Copy, or Cut with `cut`: [`CanvasEditor::clip`] goes to the clipboard.
15721579 pub fn copy(&mut self, cut: bool) -> Result<Response> {
1580 if let Some(ObjectFocus::Image(id)) = self.object_focus {
1581 let Some(clip) = self.editor.clip_picture(id)? else {
1582 return Ok(Response::default());
1583 };
1584 if cut {
1585 self.editor.remove_image(&mut self.engine, id)?;
1586 self.set_object_focus(None);
1587 }
1588 return Ok(Response {
1589 request: Some(Request::Copy(clip)),
1590 ..self.changed()?
1591 });
1592 }
1593 if self.object_focus.is_some() {
1594 return Ok(Response::default());
1595 }
15731596 let page = self.editor.whole() == Some(Whole::Page);
15741597 let Some(clip) = self.editor.clip()? else {
15751598 return Ok(Response::default());
......@@ -1585,20 +1608,33 @@ impl PageView {
15851608 })
15861609 }
15871610
1611 pub fn restore_picture_size(&mut self, id: onestore::ExGuid) -> Result<Response> {
1612 let Some(size) = self.editor.picture(id).and_then(|image| image.size) else {
1613 return Ok(Response::default());
1614 };
1615 let Some((origin, _)) = self.editor.image_placement(id) else {
1616 return Ok(Response::default());
1617 };
1618 self.editor
1619 .place_image(&mut self.engine, id, origin, size)?;
1620 self.edited()
1621 }
1622
15881623 /// A secondary press at the pointer: text there takes the caret unless it lies in the
1589 /// selection, and a file is selected, as OneNote's context menu acts where it opens.
1590 /// `None` off text and files.
1624 /// selection, and a picture or file is selected.
1625 /// `None` off text, pictures and files.
15911626 pub fn context(&mut self) -> Result<Option<(Response, Context)>> {
15921627 let point = self.viewport.document_point(self.pointer);
15931628 let (id, point) = match self.hit_test(point) {
15941629 Some(Hit::Text { id, point }) => (id, point),
1595 Some(Hit::File(id)) => {
1596 let attachment = self.editor.attachment(id).cloned();
1597 self.set_object_focus(Some(ObjectFocus::File(id)));
1598 let context = Context {
1599 attachment,
1600 ..Context::default()
1630 Some(Hit::File(id)) | Some(Hit::Image { id, .. }) => {
1631 let focus = if self.editor.attachment(id).is_some() {
1632 ObjectFocus::File(id)
1633 } else {
1634 ObjectFocus::Image(id)
16011635 };
1636 self.set_object_focus(Some(focus));
1637 let context = self.caret_context().unwrap_or_default();
16021638 return Ok(Some((self.changed()?, context)));
16031639 }
16041640 _ => return Ok(None),
......@@ -1622,9 +1658,16 @@ impl PageView {
16221658 Ok(Some((self.changed()?, context)))
16231659 }
16241660
1625 /// What a context menu at the caret or the selected file acts on; `None` where neither
1661 /// What a context menu at the caret or the selected object acts on; `None` where neither
16261662 /// takes one.
16271663 pub fn caret_context(&self) -> Option<Context> {
1664 if let Some(ObjectFocus::Image(id)) = self.object_focus {
1665 return Some(Context {
1666 image: self.editor.picture(id).cloned(),
1667 selected: true,
1668 ..Context::default()
1669 });
1670 }
16281671 if let Some(ObjectFocus::File(id)) = self.object_focus {
16291672 return Some(Context {
16301673 attachment: self.editor.attachment(id).cloned(),
......@@ -1649,6 +1692,7 @@ impl PageView {
16491692 .leaf(anchor.min(focus).paragraph)
16501693 .map(|(_, _, node)| node.id),
16511694 attachment: None,
1695 image: None,
16521696 spelling: self.selected_correction(),
16531697 })
16541698 }
......@@ -1991,7 +2035,7 @@ impl PageView {
19912035 self.changed()
19922036 }
19932037
1994 /// Draws page-level pictures an edit or undo brought to the page.
2038 /// Draws pictures and file previews brought by an edit or undo.
19952039 fn follow_pictures(&mut self) -> Result<()> {
19962040 if let Some((scene, _)) = &mut self.scene {
19972041 scene.follow(&mut self.editor, &mut self.engine)?;
crates/canvas/src/interaction/tests.rs+144
......@@ -1593,6 +1593,150 @@ fn picture_view() -> (PageView, onestore::ExGuid) {
15931593 (view, id)
15941594}
15951595
1596#[test]
1597fn a_picture_context_copies_and_cuts_the_picture_instead_of_hidden_text() {
1598 use onestore::page::{PageObject, ParagraphContent};
1599 for floating in [false, true] {
1600 let (mut view, _) = picture_view();
1601 let bytes = include_bytes!("../../../../corpus/object-tags/native/notebook/photo.png");
1602 let id = if floating {
1603 let _ = view
1604 .drop_picture([400.0, 200.0], bytes.to_vec(), [40.0, 30.0])
1605 .unwrap();
1606 view.editor
1607 .page()
1608 .unwrap()
1609 .objects
1610 .iter()
1611 .find_map(|object| match object {
1612 PageObject::Image(image) => Some(image.id),
1613 _ => None,
1614 })
1615 .unwrap()
1616 } else {
1617 let image = crate::editor::picture(bytes.to_vec(), [40.0, 30.0]).unwrap();
1618 let id = image.id;
1619 view.editor.insert_picture(&mut view.engine, image).unwrap();
1620 id
1621 };
1622 let (scene, _) =
1623 PageScene::from_page(view.editor.page().unwrap(), &mut view.engine).unwrap();
1624 view.scene = Some((scene, [0.0; 2]));
1625 let (origin, size) = view.editor.image_placement(id).unwrap();
1626 let _ = view
1627 .pointer_moved([origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0])
1628 .unwrap();
1629 let (_, context) = view.context().unwrap().unwrap();
1630 assert_eq!(context.image.as_ref().unwrap().id, id);
1631 assert!(context.selected);
1632 let copied = view.copy(false).unwrap();
1633 let Some(Request::Copy(clip)) = copied.request else {
1634 panic!("a picture copy");
1635 };
1636 assert_eq!(clip.paragraphs.len(), 1);
1637 let ParagraphContent::Image(picture) = &clip.paragraphs[0].content else {
1638 panic!("a picture");
1639 };
1640 assert_eq!(picture.id, id);
1641 assert_eq!([picture.layout.x, picture.layout.y], [None; 2]);
1642 assert_eq!(picture.bytes.as_deref(), Some(bytes.as_slice()));
1643 assert_eq!(clip.text(), "");
1644 assert!(view.editor.image_placement(id).is_some());
1645 let cut = view.copy(true).unwrap();
1646 assert!(cut.changed);
1647 assert!(matches!(cut.request, Some(Request::Copy(_))));
1648 assert!(view.editor.image_placement(id).is_none());
1649 assert!(view.undo(false).unwrap().changed);
1650 assert!(view.editor.image_placement(id).is_some());
1651 assert!(view.undo(true).unwrap().changed);
1652 assert!(view.editor.image_placement(id).is_none());
1653 view.editor
1654 .place_caret(&mut view.engine, [600.0, 400.0], 240.0)
1655 .unwrap();
1656 assert!(view.paste_clip(clip).unwrap().changed);
1657 let page = view.editor.page().unwrap();
1658 assert!(
1659 page.objects
1660 .iter()
1661 .filter_map(|object| match object {
1662 PageObject::Outline(outline) => Some(outline),
1663 _ => None,
1664 })
1665 .flat_map(|outline| &outline.paragraphs)
1666 .filter_map(|node| node.text())
1667 .any(|text| text.text.text() == "Before")
1668 );
1669 let pasted = view
1670 .editor
1671 .outlines()
1672 .iter()
1673 .flat_map(|outline| crate::document::descendants(outline.document().nodes(), None))
1674 .find_map(|(_, _, node)| match &node.content {
1675 ParagraphContent::Image(image) if image.bytes.is_some() => Some(image.id),
1676 _ => None,
1677 })
1678 .unwrap();
1679 let (scene, _) = view.scene.as_mut().unwrap();
1680 scene.settle(Some(&view.editor), 1.0, crate::gpu::Paper::WHITE);
1681 assert!(scene.image(pasted).is_some());
1682 }
1683}
1684
1685#[test]
1686fn restoring_a_pictures_original_size_is_undoable_in_an_outline_and_on_the_page() {
1687 for floating in [false, true] {
1688 let (mut view, _) = picture_view();
1689 let bytes = include_bytes!("../../../../corpus/object-tags/native/notebook/photo.png");
1690 let id = if floating {
1691 let _ = view
1692 .drop_picture([400.0, 200.0], bytes.to_vec(), [40.0, 30.0])
1693 .unwrap();
1694 view.editor
1695 .page()
1696 .unwrap()
1697 .objects
1698 .iter()
1699 .find_map(|object| match object {
1700 onestore::page::PageObject::Image(image) => Some(image.id),
1701 _ => None,
1702 })
1703 .unwrap()
1704 } else {
1705 let image = crate::editor::picture(bytes.to_vec(), [40.0, 30.0]).unwrap();
1706 let id = image.id;
1707 view.editor.insert_picture(&mut view.engine, image).unwrap();
1708 id
1709 };
1710 let (scene, _) =
1711 PageScene::from_page(view.editor.page().unwrap(), &mut view.engine).unwrap();
1712 view.scene = Some((scene, [0.0; 2]));
1713 let (origin, _) = view.editor.image_placement(id).unwrap();
1714 view.editor
1715 .place_image(&mut view.engine, id, origin, [80.0, 60.0])
1716 .unwrap();
1717 let _ = view
1718 .pointer_moved([origin[0] + 10.0, origin[1] + 10.0])
1719 .unwrap();
1720 let (_, context) = view.context().unwrap().unwrap();
1721 assert_eq!(context.image.unwrap().id, id);
1722 assert!(view.restore_picture_size(id).unwrap().changed);
1723 assert_eq!(
1724 view.editor.image_placement(id),
1725 Some((origin, [40.0, 30.0]))
1726 );
1727 assert!(view.undo(false).unwrap().changed);
1728 assert_eq!(
1729 view.editor.image_placement(id),
1730 Some((origin, [80.0, 60.0]))
1731 );
1732 assert!(view.undo(true).unwrap().changed);
1733 assert_eq!(
1734 view.editor.image_placement(id),
1735 Some((origin, [40.0, 30.0]))
1736 );
1737 }
1738}
1739
15961740#[test]
15971741fn a_page_outline_emptied_by_backspace_still_draws() {
15981742 let mut engine = TextEngine::default();
crates/snowbound/src/clipboard_linux.rs+44-7
......@@ -47,8 +47,12 @@ const CLIP: &str = "application/x-snowbound-clip";
4747/// How long a paste waits on the app that copied.
4848const PATIENCE: Duration = Duration::from_secs(2);
4949
50/// The HTML and Snowbound's own format of this process's last copy through X11.
51static LAST_COPY: Mutex<Option<(String, String)>> = Mutex::new(None);
50enum CopyFormat {
51 Html(String),
52 Picture(arboard::ImageData<'static>),
53}
54
55static LAST_COPY: Mutex<Option<(CopyFormat, String)>> = Mutex::new(None);
5256
5357pub enum Clipboard {
5458 Wayland(Box<Wayland>),
......@@ -82,28 +86,61 @@ impl Clipboard {
8286 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), Box<dyn Error>> {
8387 match self {
8488 Self::Wayland(wayland) => {
89 if let Some(picture) = &copied.picture {
90 return wayland.copy(vec![
91 ("image/png", picture.clone()),
92 (CLIP, copied.clip.clone().into_bytes()),
93 ]);
94 }
8595 let text = TEXT.map(|kind| (kind, copied.text.clone().into_bytes()));
8696 let rich = [("text/html", &copied.html), (CLIP, &copied.clip)]
8797 .map(|(kind, bytes)| (kind, bytes.clone().into_bytes()));
8898 wayland.copy(text.into_iter().chain(rich).collect())
8999 }
90100 Self::X11(clipboard) => {
101 if let Some(picture) = &copied.picture {
102 let ([width, height], bytes) = crate::paste::pixels(picture)?;
103 let image = arboard::ImageData {
104 width: width as usize,
105 height: height as usize,
106 bytes: bytes.into(),
107 };
108 clipboard.set_image(image.clone())?;
109 *LAST_COPY.lock().unwrap() =
110 Some((CopyFormat::Picture(image), copied.clip.clone()));
111 return Ok(());
112 }
91113 clipboard.set().html(&copied.html, Some(&copied.text))?;
92 *LAST_COPY.lock().unwrap() = Some((copied.html.clone(), copied.clip.clone()));
114 *LAST_COPY.lock().unwrap() =
115 Some((CopyFormat::Html(copied.html.clone()), copied.clip.clone()));
93116 Ok(())
94117 }
95118 }
96119 }
97120
98 /// What Snowbound itself copied, if it did: on X11, which arboard offers no format of
99 /// Snowbound's own through, the last copy's, while the clipboard holds its HTML.
121 /// On X11, the last clip while the clipboard still holds its HTML or pixels.
100122 pub fn get_clip(&mut self) -> Option<String> {
101123 if let Self::Wayland(wayland) = self {
102124 return String::from_utf8(wayland.paste(CLIP)?).ok();
103125 }
104 let html = self.get_html()?;
126 let Self::X11(clipboard) = self else {
127 return None;
128 };
105129 match &*LAST_COPY.lock().unwrap() {
106 Some((copied, clip)) if *copied == html => Some(clip.clone()),
130 Some((CopyFormat::Html(copied), clip))
131 if clipboard.get().html().ok().as_ref() == Some(copied) =>
132 {
133 Some(clip.clone())
134 }
135 Some((CopyFormat::Picture(copied), clip)) => clipboard
136 .get_image()
137 .ok()
138 .filter(|image| {
139 image.width == copied.width
140 && image.height == copied.height
141 && image.bytes == copied.bytes
142 })
143 .map(|_| clip.clone()),
107144 _ => None,
108145 }
109146 }
crates/snowbound/src/commands.rs+7-1
......@@ -1122,7 +1122,13 @@ impl State {
11221122 // Edit commands act on a focused field instead of the page.
11231123 let field = self.ui.focused_field().is_some();
11241124 let [anchor, focus] = self.view.editor.selection().positions;
1125 let selected = page && !field && typing && anchor != focus;
1125 let selected = page
1126 && !field
1127 && (typing && anchor != focus
1128 || matches!(
1129 self.view.object_focus(),
1130 Some(canvas::interaction::ObjectFocus::Image(_))
1131 ));
11261132 let enabled = |enabled| Status {
11271133 enabled,
11281134 checked: None,
crates/snowbound/src/link.rs+92-2
......@@ -250,7 +250,7 @@ impl State {
250250 Ok(())
251251 }
252252
253 /// A secondary press on the page's text or a file opens its context menu there.
253 /// A secondary press on text, a picture or a file opens its context menu there.
254254 pub(crate) fn open_text_menu(&mut self) -> Result<(), Box<dyn Error>> {
255255 let Some((response, context)) = self.view.context()? else {
256256 return Ok(());
......@@ -263,7 +263,7 @@ impl State {
263263 }
264264
265265 /// The page's context menu while it is open, with OneNote 2010's commands for text,
266 /// links, equations and files.
266 /// links, equations, pictures and files.
267267 pub(crate) fn text_menu(&mut self) -> Result<(), Box<dyn Error>> {
268268 let Some((context, point)) = &self.text_menu else {
269269 return Ok(());
......@@ -371,6 +371,53 @@ impl State {
371371 };
372372 let format = self.format_state();
373373 let disabled = |id| !self.status(&commands::Choice::Command(id), &format).enabled;
374 if let Some(image) = &context.image {
375 return vec![
376 (
377 Item {
378 disabled: disabled(commands::Id::Cut) || image.bytes.is_none(),
379 ..drawn("Cut", art::CUT)
380 },
381 "Cut",
382 ),
383 (
384 Item {
385 disabled: image.bytes.is_none(),
386 ..drawn("Copy", art::COPY)
387 },
388 "Copy",
389 ),
390 (
391 Item {
392 disabled: image.text.as_ref().is_none_or(|text| text.text.is_empty()),
393 ..drawn("Copy Text from Picture", art::COPY)
394 },
395 "Copy Text from Picture",
396 ),
397 (
398 Item {
399 separated: true,
400 disabled: image.bytes.is_none(),
401 ..drawn("Save As", art::SAVE)
402 },
403 "Save Picture As",
404 ),
405 (
406 Item {
407 separated: true,
408 disabled: disabled(commands::Id::Cut)
409 || image.size.is_none_or(|size| {
410 self.view
411 .editor
412 .image_placement(image.id)
413 .is_none_or(|(_, shown)| shown == size)
414 }),
415 ..drawn("Restore to Original Size", art::PICTURE)
416 },
417 "Restore to Original Size",
418 ),
419 ];
420 }
374421 // Paste stands for any edit to the text here.
375422 let (fixed, unlinkable) = (disabled(commands::Id::Paste), disabled(commands::Id::Link));
376423 if context.attachment.is_some() {
......@@ -471,6 +518,49 @@ impl State {
471518 context: Context,
472519 text: &str,
473520 ) -> Result<(), Box<dyn Error>> {
521 if let Some(image) = &context.image {
522 match text {
523 "Save As" => {
524 if let Some(bytes) = &image.bytes {
525 let name = if bytes.starts_with(b"\xff\xd8") {
526 "Picture.jpg"
527 } else if bytes.starts_with(b"GIF8") {
528 "Picture.gif"
529 } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
530 "Picture.png"
531 } else {
532 "Picture"
533 };
534 #[cfg(target_arch = "wasm32")]
535 platform::download(name, bytes, "application/octet-stream");
536 #[cfg(not(target_arch = "wasm32"))]
537 platform::pick_new(
538 "Save As",
539 name,
540 "Save",
541 None,
542 self.reply({
543 let bytes = bytes.clone();
544 move |_, path| Ok(notebook::fs::write(path, bytes)?)
545 }),
546 );
547 }
548 return Ok(());
549 }
550 "Copy Text from Picture" => {
551 if let Some(text) = &image.text {
552 self.clipboard.set_text(text.text.clone())?;
553 }
554 return Ok(());
555 }
556 "Restore to Original Size" => {
557 let response = self.view.restore_picture_size(image.id)?;
558 self.respond(response);
559 return Ok(());
560 }
561 _ => {}
562 }
563 }
474564 if let Some(file) = &context.attachment {
475565 return match text {
476566 "Open" => self.open_attachment(file),
crates/snowbound/src/macos.rs+24-2
......@@ -260,6 +260,17 @@ impl Clipboard {
260260 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), &'static str> {
261261 let set = unsafe {
262262 let _: isize = msg_send![&self.0, clearContents];
263 if let Some(picture) = &copied.picture {
264 let data: Retained<AnyObject> = msg_send_id![class!(NSData), dataWithBytes: picture.as_ptr().cast::<std::ffi::c_void>(), length: picture.len()];
265 let kind = NSString::from_str("public.png");
266 let set: bool = msg_send![&self.0, setData: &*data, forType: &*kind];
267 let clip = NSString::from_str(&copied.clip);
268 let kind = NSString::from_str(CLIP);
269 let own: bool = msg_send![&self.0, setString: &*clip, forType: &*kind];
270 return (set && own)
271 .then_some(())
272 .ok_or("The pasteboard refused the picture");
273 }
263274 [
264275 (TEXT, &copied.text),
265276 ("public.html", &copied.html),
......@@ -368,8 +379,8 @@ unsafe fn ns_data_bytes(data: &AnyObject) -> Vec<u8> {
368379 if length == 0 {
369380 return Vec::new();
370381 }
371 let bytes: *const u8 = msg_send![data, bytes];
372 std::slice::from_raw_parts(bytes, length).to_vec()
382 let bytes: *const std::ffi::c_void = msg_send![data, bytes];
383 std::slice::from_raw_parts(bytes.cast::<u8>(), length).to_vec()
373384 }
374385}
375386
......@@ -1748,12 +1759,23 @@ mod tests {
17481759 let copied = crate::paste::Copied {
17491760 text: "Copied".into(),
17501761 html: "<p>Copied</p>".into(),
1762 picture: None,
17511763 clip: "{}".into(),
17521764 };
17531765 clipboard.set(&copied).unwrap();
17541766 assert_eq!(clipboard.get_text().unwrap(), "Copied");
17551767 assert_eq!(clipboard.get_html().as_deref(), Some("<p>Copied</p>"));
17561768 assert_eq!(clipboard.get_clip().as_deref(), Some("{}"));
1769 let picture = include_bytes!("../../../corpus/object-tags/native/notebook/photo.png");
1770 let copied = crate::paste::Copied {
1771 picture: Some(picture.to_vec()),
1772 ..copied
1773 };
1774 clipboard.set(&copied).unwrap();
1775 assert_eq!(clipboard.get_picture().as_deref(), Some(picture.as_slice()));
1776 assert_eq!(clipboard.get_clip().as_deref(), Some("{}"));
1777 assert_eq!(clipboard.get_html(), None);
1778 assert!(clipboard.get_text().is_err());
17571779 clipboard.set_text("Plain".into()).unwrap();
17581780 assert_eq!(clipboard.get_clip(), None);
17591781 unsafe { msg_send![&board, releaseGlobally] }
crates/snowbound/src/main.rs+1-1
......@@ -348,7 +348,7 @@ impl Clipboard {
348348
349349 fn set(&mut self, clip: Clip) -> Result<(), Box<dyn Error>> {
350350 match self {
351 Self::System(clipboard) => clipboard.set(&paste::Copied::new(&clip))?,
351 Self::System(clipboard) => clipboard.set(&paste::Copied::new(&clip)?)?,
352352 Self::Memory(held, kept) => (*held, *kept) = (clip.text(), Some(clip)),
353353 }
354354 Ok(())
crates/snowbound/src/paste.rs+73-7
......@@ -8,26 +8,85 @@ use std::{
88 path::{Path, PathBuf},
99};
1010
11/// Every format Copy puts on the clipboard for one selection, which each platform's clipboard
12/// offers together: text; HTML, as OneNote 2010 offers it beside text (lab, 2026-10-02); and
13/// Snowbound's own format, [`Clip::encode`]'s JSON, which pastes losslessly into Snowbound.
14/// Its name is `net.paperclover.snowbound.clip` on macOS, `Snowbound Clip` on Windows and
15/// `application/x-snowbound-clip` where formats are MIME types.
11/// Copy offers text and HTML, or a picture as PNG, beside Snowbound's lossless clip.
1612pub(crate) struct Copied {
1713 pub text: String,
1814 /// A whole page, the copy between `<!--StartFragment-->` and `<!--EndFragment-->`.
1915 pub html: String,
2016 pub clip: String,
17 pub picture: Option<Vec<u8>>,
2118}
2219
2320impl Copied {
24 pub(crate) fn new(clip: &Clip) -> Self {
25 Self {
21 pub(crate) fn new(clip: &Clip) -> Result<Self, Box<dyn Error>> {
22 let picture = match clip.paragraphs.as_slice() {
23 [
24 onestore::page::PageParagraph {
25 content: onestore::page::ParagraphContent::Image(image),
26 ..
27 },
28 ] => image.display.as_deref().or(image.bytes.as_deref()),
29 _ => None,
30 };
31 let picture = picture
32 .map(|bytes| -> Result<Vec<u8>, Box<dyn Error>> {
33 if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
34 Ok(bytes.to_vec())
35 } else {
36 let (size, rgba) = pixels(bytes)?;
37 Ok(png(size, &rgba)?)
38 }
39 })
40 .transpose()?;
41 Ok(Self {
2642 text: clip.text(),
2743 html: clip.html(),
2844 clip: clip.encode(),
45 picture,
46 })
47 }
48}
49
50pub(crate) fn pixels(encoded: &[u8]) -> Result<([u32; 2], Vec<u8>), Box<dyn Error>> {
51 if !encoded.starts_with(b"\x89PNG\r\n\x1a\n") {
52 let image = draw::RasterImage::decode(encoded, [u32::MAX; 2])
53 .map_err(|error| format!("{error:?}"))?;
54 let mut rgba = image.pixels().to_vec();
55 for pixel in rgba.chunks_exact_mut(4) {
56 let alpha = u32::from(pixel[3]);
57 for channel in &mut pixel[..3] {
58 *channel = (u32::from(*channel) * 255 + alpha / 2)
59 .checked_div(alpha)
60 .unwrap_or(0)
61 .min(255) as u8;
62 }
2963 }
64 return Ok((image.size(), rgba));
65 }
66 let size = draw::RasterImage::measure(encoded).map_err(|error| format!("{error:?}"))?;
67 if u64::from(size[0]) * u64::from(size[1]) * 4 > draw::MAX_IMAGE_BYTES {
68 return Err("The picture is too large for the clipboard".into());
3069 }
70 let mut decoder = png::Decoder::new(std::io::Cursor::new(encoded));
71 decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
72 let mut reader = decoder.read_info()?;
73 let mut bytes = vec![
74 0;
75 reader
76 .output_buffer_size()
77 .ok_or("The picture is too large")?
78 ];
79 let info = reader.next_frame(&mut bytes)?;
80 let rgba = bytes[..info.buffer_size()]
81 .chunks_exact(info.color_type.samples())
82 .flat_map(|pixel| match info.color_type {
83 png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
84 png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
85 png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
86 _ => [pixel[0], pixel[0], pixel[0], 255],
87 })
88 .collect();
89 Ok(([info.width, info.height], rgba))
3190}
3291
3392/// `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<Vec<u8>, png::EncodingE
421480mod tests {
422481 use super::*;
423482
483 #[test]
484 fn clipboard_pixels_keep_straight_alpha() {
485 let rgba = [239, 17, 123, 37, 8, 49, 203, 0];
486 let encoded = png([2, 1], &rgba).unwrap();
487 assert_eq!(pixels(&encoded).unwrap(), ([2, 1], rgba.to_vec()));
488 }
489
424490 fn png_at(pixels_per_metre: Option<u32>) -> Vec<u8> {
425491 let mut bytes = Vec::new();
426492 let mut encoder = png::Encoder::new(&mut bytes, 200, 100);
crates/snowbound/src/web.rs+9-2
......@@ -44,6 +44,8 @@ extern "C" {
4444 fn place_input(x: f32, y: f32, height: f32, text: bool);
4545 #[wasm_bindgen(js_name = writeClipboard)]
4646 fn write_clipboard(text: &str);
47 #[wasm_bindgen(js_name = copyPicture)]
48 fn copy_picture(bytes: &[u8]);
4749 /// Hands `bytes` to the browser to save as `name`, of MIME type `kind`.
4850 pub fn download(name: &str, bytes: &[u8], kind: &str);
4951 #[wasm_bindgen(js_name = pickFiles)]
......@@ -523,9 +525,14 @@ impl Clipboard {
523525 Ok(())
524526 }
525527
526 /// A copy's text, the format every browser's clipboard takes.
528 /// Text or a PNG picture on the browser's clipboard.
527529 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), Box<dyn Error>> {
528 self.set_text(copied.text.clone())
530 if let Some(picture) = &copied.picture {
531 copy_picture(picture);
532 Ok(())
533 } else {
534 self.set_text(copied.text.clone())
535 }
529536 }
530537
531538 pub fn get_clip(&mut self) -> Option<String> {
crates/snowbound/src/windows.rs+42-15
......@@ -1112,22 +1112,49 @@ impl Clipboard {
11121112 self.0.set_text(text)
11131113 }
11141114
1115 /// Text, `HTML Format` and Snowbound's own, as one copy.
1115 /// Text and HTML, or a PNG and DIB, beside Snowbound's lossless clip.
11161116 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), &'static str> {
1117 use windows_sys::Win32::System::{DataExchange as clip, Memory, Ole::CF_UNICODETEXT};
1118 let text: Vec<u8> = copied
1119 .text
1120 .replace('\n', "\r\n")
1121 .encode_utf16()
1122 .chain([0])
1123 .flat_map(u16::to_le_bytes)
1124 .collect();
1125 let html = crate::paste::cf_html(&copied.html);
1126 let formats = [
1127 (u32::from(CF_UNICODETEXT), text),
1128 (registered("HTML Format"), [html.as_bytes(), &[0]].concat()),
1129 (registered(CLIP), [copied.clip.as_bytes(), &[0]].concat()),
1130 ];
1117 use windows_sys::Win32::System::{
1118 DataExchange as clip, Memory,
1119 Ole::{CF_DIB, CF_UNICODETEXT},
1120 };
1121 let formats = if let Some(picture) = &copied.picture {
1122 let (size, mut rgba) =
1123 crate::paste::pixels(picture).map_err(|_| "The picture couldn't be decoded")?;
1124 for pixel in rgba.chunks_exact_mut(4) {
1125 pixel.swap(0, 2);
1126 }
1127 let header = [
1128 40_u32.to_le_bytes().as_slice(),
1129 (size[0] as i32).to_le_bytes().as_slice(),
1130 (-(size[1] as i32)).to_le_bytes().as_slice(),
1131 1_u16.to_le_bytes().as_slice(),
1132 32_u16.to_le_bytes().as_slice(),
1133 0_u32.to_le_bytes().as_slice(),
1134 (rgba.len() as u32).to_le_bytes().as_slice(),
1135 &[0; 16],
1136 ]
1137 .concat();
1138 vec![
1139 (u32::from(CF_DIB), [header, rgba].concat()),
1140 (registered("PNG"), picture.clone()),
1141 (registered(CLIP), [copied.clip.as_bytes(), &[0]].concat()),
1142 ]
1143 } else {
1144 let text: Vec<u8> = copied
1145 .text
1146 .replace('\n', "\r\n")
1147 .encode_utf16()
1148 .chain([0])
1149 .flat_map(u16::to_le_bytes)
1150 .collect();
1151 let html = crate::paste::cf_html(&copied.html);
1152 vec![
1153 (u32::from(CF_UNICODETEXT), text),
1154 (registered("HTML Format"), [html.as_bytes(), &[0]].concat()),
1155 (registered(CLIP), [copied.clip.as_bytes(), &[0]].concat()),
1156 ]
1157 };
11311158 let _open = Open::new(self.1)?;
11321159 if unsafe { clip::EmptyClipboard() } == 0 {
11331160 return Err("The clipboard couldn't be emptied");
crates/snowbound/web/glue.js+5
......@@ -443,6 +443,11 @@ export function writeClipboard(text) {
443443 navigator.clipboard.writeText(text).catch((error) => console.warn("Copy", error));
444444}
445445
446export function copyPicture(bytes) {
447 navigator.clipboard.write([new ClipboardItem({ "image/png": new Blob([bytes], { type: "image/png" }) })])
448 .catch((error) => console.warn("Copy", error));
449}
450
446451export function download(name, bytes, kind) {
447452 const url = URL.createObjectURL(new Blob([bytes], { type: kind }));
448453 const link = Object.assign(document.createElement("a"), { href: url, download: name });