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 {...@@ -2069,14 +2069,15 @@ impl CanvasEditor {
20692069
2070 /// The address picture `id` links to, in an outline or on the page.2070 /// The address picture `id` links to, in an outline or on the page.
2071 pub(crate) fn picture_link(&self, id: ExGuid) -> Option<&str> {2071 pub(crate) fn picture_link(&self, id: ExGuid) -> Option<&str> {
2072 let image = match self.image(id) {2072 self.picture(id)?.link.as_deref()
2073 Some(image) => image,2073 }
2074 None => match &self.outline_picture(id)?.3.content {2074
2075 ParagraphContent::Image(image) => image,2075 pub(crate) fn picture(&self, id: ExGuid) -> Option<&onestore::page::Image> {
2076 _ => return None,2076 self.image(id)
2077 },2077 .or_else(|| match &self.outline_picture(id)?.3.content {
2078 };2078 ParagraphContent::Image(image) => Some(image),
2079 image.link.as_deref()2079 _ => None,
2080 })
2080 }2081 }
20812082
2082 /// The file a paragraph holds or the page shows, which the host opens and saves.2083 /// 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 {...@@ -56,6 +56,22 @@ impl Clip {
56}56}
5757
58impl CanvasEditor {58impl 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
59 /// What Copy takes: the selection's paragraphs with the first and last cut to it, or the75 /// What Copy takes: the selection's paragraphs with the first and last cut to it, or the
60 /// page selection's outlines top to bottom with an empty paragraph between, as OneNote76 /// page selection's outlines top to bottom with an empty paragraph between, as OneNote
61 /// 2010 copies them. None where nothing shows.77 /// 2010 copies them. None where nothing shows.
...@@ -156,17 +172,10 @@ impl CanvasEditor {...@@ -156,17 +172,10 @@ impl CanvasEditor {
156 let added = leaves(&nodes, None).count();172 let added = leaves(&nodes, None).count();
157 edit.replacement.splice(1..=count, nodes);173 edit.replacement.splice(1..=count, nodes);
158 let head = drop_empty_halves(&mut edit, count, ends_in_text);174 let head = drop_empty_halves(&mut edit, count, ends_in_text);
159 let paragraph = start.paragraph + added - usize::from(head);175 let paragraph = start.paragraph + added + usize::from(!ends_in_text) - usize::from(head);
160 let caret = if ends_in_text {176 let caret = TextPosition {
161 TextPosition {177 paragraph,
162 paragraph,178 offset: last,
163 offset: last,
164 }
165 } else {
166 TextPosition {
167 paragraph: paragraph + 1,
168 offset: 0,
169 }
170 };179 };
171 self.commit(engine, edit, [caret; 2].into())180 self.commit(engine, edit, [caret; 2].into())
172 }181 }
crates/canvas/src/gpu/page.rs+33-19
...@@ -316,8 +316,7 @@ impl PageScene {...@@ -316,8 +316,7 @@ impl PageScene {
316 Ok(())316 Ok(())
317 }317 }
318318
319 /// Follows `editor` after an edit or undo brought page-level pictures the scene has not319 /// Follows pictures and file previews brought by an edit or undo.
320 /// seen, as `refresh` does.
321 pub(crate) fn follow(320 pub(crate) fn follow(
322 &mut self,321 &mut self,
323 editor: &mut CanvasEditor,322 editor: &mut CanvasEditor,
...@@ -332,7 +331,20 @@ impl PageScene {...@@ -332,7 +331,20 @@ impl PageScene {
332 }331 }
333 _ => true,332 _ => true,
334 };333 };
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 {
336 return Ok(());348 return Ok(());
337 }349 }
338 self.refresh(editor, engine)350 self.refresh(editor, engine)
...@@ -400,14 +412,15 @@ impl PageScene {...@@ -400,14 +412,15 @@ impl PageScene {
400 payloads.push((source.id, [source.preview.as_ref(), None]))412 payloads.push((source.id, [source.preview.as_ref(), None]))
401 }413 }
402 Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads),414 Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads),
403 Content::Editable(id) => {415 Content::Editable(_)
404 if let Some(outline) =416 | Content::Date { .. }
405 editor.and_then(|editor| editor.outlines().iter().find(|o| o.id == *id))417 | Content::Ink(_)
406 {418 | Content::ReadOnly(_) => {}
407 nested(outline.document().nodes(), &mut payloads);419 }
408 }420 }
409 }421 if let Some(editor) = editor {
410 Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {}422 for outline in editor.outlines() {
423 nested(outline.document().nodes(), &mut payloads);
411 }424 }
412 }425 }
413 let mut pictures = std::collections::BTreeMap::new();426 let mut pictures = std::collections::BTreeMap::new();
...@@ -515,14 +528,15 @@ impl PageScene {...@@ -515,14 +528,15 @@ impl PageScene {
515 outline(layout, origin, &mut rects);528 outline(layout, origin, &mut rects);
516 }529 }
517 }530 }
518 Content::Editable(id) => {531 Content::Editable(_)
519 if let Some(editable) =532 | Content::Date { .. }
520 editor.and_then(|editor| editor.visible_outlines().find(|o| o.id == *id))533 | Content::Ink(_)
521 {534 | Content::ReadOnly(_) => {}
522 outline(editable.shaped(), editable.origin(), &mut rects);535 }
523 }536 }
524 }537 if let Some(editor) = editor {
525 Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {}538 for editable in editor.visible_outlines() {
539 outline(editable.shaped(), editable.origin(), &mut rects);
526 }540 }
527 }541 }
528 rects542 rects
crates/canvas/src/interaction/mod.rs+58-14
...@@ -79,12 +79,13 @@ pub struct Context {...@@ -79,12 +79,13 @@ pub struct Context {
79 /// The address of the link at the caret.79 /// The address of the link at the caret.
80 pub link: Option<String>,80 pub link: Option<String>,
81 pub equation: bool,81 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.
83 pub selected: bool,83 pub selected: bool,
84 /// The paragraph at the caret, which Copy Link to Paragraph names.84 /// The paragraph at the caret, which Copy Link to Paragraph names.
85 pub paragraph: Option<onestore::ExGuid>,85 pub paragraph: Option<onestore::ExGuid>,
86 /// The file the press selected, which Open and Save As take.86 /// The file the press selected, which Open and Save As take.
87 pub attachment: Option<onestore::page::Attachment>,87 pub attachment: Option<onestore::page::Attachment>,
88 pub image: Option<onestore::page::Image>,
88 /// The marked word the press landed on, which the menu offers corrections for.89 /// The marked word the press landed on, which the menu offers corrections for.
89 pub spelling: Option<Correction>,90 pub spelling: Option<Correction>,
90}91}
...@@ -640,9 +641,12 @@ impl PageView {...@@ -640,9 +641,12 @@ impl PageView {
640641
641 /// Page coordinates of a focused object.642 /// Page coordinates of a focused object.
642 fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] {643 fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] {
643 let (scene, offset) = self.scene.as_ref().unwrap();
644 let rect = match focus {644 let rect = match focus {
645 ObjectFocus::ReadOnly(index) => scene645 ObjectFocus::ReadOnly(index) => self
646 .scene
647 .as_ref()
648 .unwrap()
649 .0
646 .read_only(Some(&self.editor))650 .read_only(Some(&self.editor))
647 .nth(index)651 .nth(index)
648 .unwrap()652 .unwrap()
...@@ -653,7 +657,10 @@ impl PageView {...@@ -653,7 +657,10 @@ impl PageView {
653 }657 }
654 ObjectFocus::File(id) => self.editor.attachment_rect(id).unwrap(),658 ObjectFocus::File(id) => self.editor.attachment_rect(id).unwrap(),
655 };659 };
656 crate::translated(rect, *offset)660 crate::translated(
661 rect,
662 self.scene.as_ref().map_or([0.0; 2], |(_, offset)| *offset),
663 )
657 }664 }
658665
659 /// Keeps the view in bounds and restarts the caret blink after a change.666 /// Keeps the view in bounds and restarts the caret blink after a change.
...@@ -1570,6 +1577,22 @@ impl PageView {...@@ -1570,6 +1577,22 @@ impl PageView {
15701577
1571 /// Copy, or Cut with `cut`: [`CanvasEditor::clip`] goes to the clipboard.1578 /// Copy, or Cut with `cut`: [`CanvasEditor::clip`] goes to the clipboard.
1572 pub fn copy(&mut self, cut: bool) -> Result<Response> {1579 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 }
1573 let page = self.editor.whole() == Some(Whole::Page);1596 let page = self.editor.whole() == Some(Whole::Page);
1574 let Some(clip) = self.editor.clip()? else {1597 let Some(clip) = self.editor.clip()? else {
1575 return Ok(Response::default());1598 return Ok(Response::default());
...@@ -1585,20 +1608,33 @@ impl PageView {...@@ -1585,20 +1608,33 @@ impl PageView {
1585 })1608 })
1586 }1609 }
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
1588 /// A secondary press at the pointer: text there takes the caret unless it lies in the1623 /// 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.1624 /// selection, and a picture or file is selected.
1590 /// `None` off text and files.1625 /// `None` off text, pictures and files.
1591 pub fn context(&mut self) -> Result<Option<(Response, Context)>> {1626 pub fn context(&mut self) -> Result<Option<(Response, Context)>> {
1592 let point = self.viewport.document_point(self.pointer);1627 let point = self.viewport.document_point(self.pointer);
1593 let (id, point) = match self.hit_test(point) {1628 let (id, point) = match self.hit_test(point) {
1594 Some(Hit::Text { id, point }) => (id, point),1629 Some(Hit::Text { id, point }) => (id, point),
1595 Some(Hit::File(id)) => {1630 Some(Hit::File(id)) | Some(Hit::Image { id, .. }) => {
1596 let attachment = self.editor.attachment(id).cloned();1631 let focus = if self.editor.attachment(id).is_some() {
1597 self.set_object_focus(Some(ObjectFocus::File(id)));1632 ObjectFocus::File(id)
1598 let context = Context {1633 } else {
1599 attachment,1634 ObjectFocus::Image(id)
1600 ..Context::default()
1601 };1635 };
1636 self.set_object_focus(Some(focus));
1637 let context = self.caret_context().unwrap_or_default();
1602 return Ok(Some((self.changed()?, context)));1638 return Ok(Some((self.changed()?, context)));
1603 }1639 }
1604 _ => return Ok(None),1640 _ => return Ok(None),
...@@ -1622,9 +1658,16 @@ impl PageView {...@@ -1622,9 +1658,16 @@ impl PageView {
1622 Ok(Some((self.changed()?, context)))1658 Ok(Some((self.changed()?, context)))
1623 }1659 }
16241660
1625 /// What a context menu at the caret or the selected file acts on; `None` where neither1661 /// What a context menu at the caret or the selected object acts on; `None` where neither
1626 /// takes one.1662 /// takes one.
1627 pub fn caret_context(&self) -> Option<Context> {1663 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 }
1628 if let Some(ObjectFocus::File(id)) = self.object_focus {1671 if let Some(ObjectFocus::File(id)) = self.object_focus {
1629 return Some(Context {1672 return Some(Context {
1630 attachment: self.editor.attachment(id).cloned(),1673 attachment: self.editor.attachment(id).cloned(),
...@@ -1649,6 +1692,7 @@ impl PageView {...@@ -1649,6 +1692,7 @@ impl PageView {
1649 .leaf(anchor.min(focus).paragraph)1692 .leaf(anchor.min(focus).paragraph)
1650 .map(|(_, _, node)| node.id),1693 .map(|(_, _, node)| node.id),
1651 attachment: None,1694 attachment: None,
1695 image: None,
1652 spelling: self.selected_correction(),1696 spelling: self.selected_correction(),
1653 })1697 })
1654 }1698 }
...@@ -1991,7 +2035,7 @@ impl PageView {...@@ -1991,7 +2035,7 @@ impl PageView {
1991 self.changed()2035 self.changed()
1992 }2036 }
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.
1995 fn follow_pictures(&mut self) -> Result<()> {2039 fn follow_pictures(&mut self) -> Result<()> {
1996 if let Some((scene, _)) = &mut self.scene {2040 if let Some((scene, _)) = &mut self.scene {
1997 scene.follow(&mut self.editor, &mut self.engine)?;2041 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) {...@@ -1593,6 +1593,150 @@ fn picture_view() -> (PageView, onestore::ExGuid) {
1593 (view, id)1593 (view, id)
1594}1594}
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
1596#[test]1740#[test]
1597fn a_page_outline_emptied_by_backspace_still_draws() {1741fn a_page_outline_emptied_by_backspace_still_draws() {
1598 let mut engine = TextEngine::default();1742 let mut engine = TextEngine::default();
crates/snowbound/src/clipboard_linux.rs+44-7
...@@ -47,8 +47,12 @@ const CLIP: &str = "application/x-snowbound-clip";...@@ -47,8 +47,12 @@ const CLIP: &str = "application/x-snowbound-clip";
47/// How long a paste waits on the app that copied.47/// How long a paste waits on the app that copied.
48const PATIENCE: Duration = Duration::from_secs(2);48const PATIENCE: Duration = Duration::from_secs(2);
4949
50/// The HTML and Snowbound's own format of this process's last copy through X11.50enum CopyFormat {
51static LAST_COPY: Mutex<Option<(String, String)>> = Mutex::new(None);51 Html(String),
52 Picture(arboard::ImageData<'static>),
53}
54
55static LAST_COPY: Mutex<Option<(CopyFormat, String)>> = Mutex::new(None);
5256
53pub enum Clipboard {57pub enum Clipboard {
54 Wayland(Box<Wayland>),58 Wayland(Box<Wayland>),
...@@ -82,28 +86,61 @@ impl Clipboard {...@@ -82,28 +86,61 @@ impl Clipboard {
82 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), Box<dyn Error>> {86 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), Box<dyn Error>> {
83 match self {87 match self {
84 Self::Wayland(wayland) => {88 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 }
85 let text = TEXT.map(|kind| (kind, copied.text.clone().into_bytes()));95 let text = TEXT.map(|kind| (kind, copied.text.clone().into_bytes()));
86 let rich = [("text/html", &copied.html), (CLIP, &copied.clip)]96 let rich = [("text/html", &copied.html), (CLIP, &copied.clip)]
87 .map(|(kind, bytes)| (kind, bytes.clone().into_bytes()));97 .map(|(kind, bytes)| (kind, bytes.clone().into_bytes()));
88 wayland.copy(text.into_iter().chain(rich).collect())98 wayland.copy(text.into_iter().chain(rich).collect())
89 }99 }
90 Self::X11(clipboard) => {100 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 }
91 clipboard.set().html(&copied.html, Some(&copied.text))?;113 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()));
93 Ok(())116 Ok(())
94 }117 }
95 }118 }
96 }119 }
97120
98 /// What Snowbound itself copied, if it did: on X11, which arboard offers no format of121 /// On X11, the last clip while the clipboard still holds its HTML or pixels.
99 /// Snowbound's own through, the last copy's, while the clipboard holds its HTML.
100 pub fn get_clip(&mut self) -> Option<String> {122 pub fn get_clip(&mut self) -> Option<String> {
101 if let Self::Wayland(wayland) = self {123 if let Self::Wayland(wayland) = self {
102 return String::from_utf8(wayland.paste(CLIP)?).ok();124 return String::from_utf8(wayland.paste(CLIP)?).ok();
103 }125 }
104 let html = self.get_html()?;126 let Self::X11(clipboard) = self else {
127 return None;
128 };
105 match &*LAST_COPY.lock().unwrap() {129 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()),
107 _ => None,144 _ => None,
108 }145 }
109 }146 }
crates/snowbound/src/commands.rs+7-1
...@@ -1122,7 +1122,13 @@ impl State {...@@ -1122,7 +1122,13 @@ impl State {
1122 // Edit commands act on a focused field instead of the page.1122 // Edit commands act on a focused field instead of the page.
1123 let field = self.ui.focused_field().is_some();1123 let field = self.ui.focused_field().is_some();
1124 let [anchor, focus] = self.view.editor.selection().positions;1124 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 ));
1126 let enabled = |enabled| Status {1132 let enabled = |enabled| Status {
1127 enabled,1133 enabled,
1128 checked: None,1134 checked: None,
crates/snowbound/src/link.rs+92-2
...@@ -250,7 +250,7 @@ impl State {...@@ -250,7 +250,7 @@ impl State {
250 Ok(())250 Ok(())
251 }251 }
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.
254 pub(crate) fn open_text_menu(&mut self) -> Result<(), Box<dyn Error>> {254 pub(crate) fn open_text_menu(&mut self) -> Result<(), Box<dyn Error>> {
255 let Some((response, context)) = self.view.context()? else {255 let Some((response, context)) = self.view.context()? else {
256 return Ok(());256 return Ok(());
...@@ -263,7 +263,7 @@ impl State {...@@ -263,7 +263,7 @@ impl State {
263 }263 }
264264
265 /// The page's context menu while it is open, with OneNote 2010's commands for text,265 /// 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.
267 pub(crate) fn text_menu(&mut self) -> Result<(), Box<dyn Error>> {267 pub(crate) fn text_menu(&mut self) -> Result<(), Box<dyn Error>> {
268 let Some((context, point)) = &self.text_menu else {268 let Some((context, point)) = &self.text_menu else {
269 return Ok(());269 return Ok(());
...@@ -371,6 +371,53 @@ impl State {...@@ -371,6 +371,53 @@ impl State {
371 };371 };
372 let format = self.format_state();372 let format = self.format_state();
373 let disabled = |id| !self.status(&commands::Choice::Command(id), &format).enabled;373 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 }
374 // Paste stands for any edit to the text here.421 // Paste stands for any edit to the text here.
375 let (fixed, unlinkable) = (disabled(commands::Id::Paste), disabled(commands::Id::Link));422 let (fixed, unlinkable) = (disabled(commands::Id::Paste), disabled(commands::Id::Link));
376 if context.attachment.is_some() {423 if context.attachment.is_some() {
...@@ -471,6 +518,49 @@ impl State {...@@ -471,6 +518,49 @@ impl State {
471 context: Context,518 context: Context,
472 text: &str,519 text: &str,
473 ) -> Result<(), Box<dyn Error>> {520 ) -> 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 }
474 if let Some(file) = &context.attachment {564 if let Some(file) = &context.attachment {
475 return match text {565 return match text {
476 "Open" => self.open_attachment(file),566 "Open" => self.open_attachment(file),
crates/snowbound/src/macos.rs+24-2
...@@ -260,6 +260,17 @@ impl Clipboard {...@@ -260,6 +260,17 @@ impl Clipboard {
260 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), &'static str> {260 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), &'static str> {
261 let set = unsafe {261 let set = unsafe {
262 let _: isize = msg_send![&self.0, clearContents];262 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 }
263 [274 [
264 (TEXT, &copied.text),275 (TEXT, &copied.text),
265 ("public.html", &copied.html),276 ("public.html", &copied.html),
...@@ -368,8 +379,8 @@ unsafe fn ns_data_bytes(data: &AnyObject) -> Vec<u8> {...@@ -368,8 +379,8 @@ unsafe fn ns_data_bytes(data: &AnyObject) -> Vec<u8> {
368 if length == 0 {379 if length == 0 {
369 return Vec::new();380 return Vec::new();
370 }381 }
371 let bytes: *const u8 = msg_send![data, bytes];382 let bytes: *const std::ffi::c_void = msg_send![data, bytes];
372 std::slice::from_raw_parts(bytes, length).to_vec()383 std::slice::from_raw_parts(bytes.cast::<u8>(), length).to_vec()
373 }384 }
374}385}
375386
...@@ -1748,12 +1759,23 @@ mod tests {...@@ -1748,12 +1759,23 @@ mod tests {
1748 let copied = crate::paste::Copied {1759 let copied = crate::paste::Copied {
1749 text: "Copied".into(),1760 text: "Copied".into(),
1750 html: "<p>Copied</p>".into(),1761 html: "<p>Copied</p>".into(),
1762 picture: None,
1751 clip: "{}".into(),1763 clip: "{}".into(),
1752 };1764 };
1753 clipboard.set(&copied).unwrap();1765 clipboard.set(&copied).unwrap();
1754 assert_eq!(clipboard.get_text().unwrap(), "Copied");1766 assert_eq!(clipboard.get_text().unwrap(), "Copied");
1755 assert_eq!(clipboard.get_html().as_deref(), Some("<p>Copied</p>"));1767 assert_eq!(clipboard.get_html().as_deref(), Some("<p>Copied</p>"));
1756 assert_eq!(clipboard.get_clip().as_deref(), Some("{}"));1768 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());
1757 clipboard.set_text("Plain".into()).unwrap();1779 clipboard.set_text("Plain".into()).unwrap();
1758 assert_eq!(clipboard.get_clip(), None);1780 assert_eq!(clipboard.get_clip(), None);
1759 unsafe { msg_send![&board, releaseGlobally] }1781 unsafe { msg_send![&board, releaseGlobally] }
crates/snowbound/src/main.rs+1-1
...@@ -348,7 +348,7 @@ impl Clipboard {...@@ -348,7 +348,7 @@ impl Clipboard {
348348
349 fn set(&mut self, clip: Clip) -> Result<(), Box<dyn Error>> {349 fn set(&mut self, clip: Clip) -> Result<(), Box<dyn Error>> {
350 match self {350 match self {
351 Self::System(clipboard) => clipboard.set(&paste::Copied::new(&clip))?,351 Self::System(clipboard) => clipboard.set(&paste::Copied::new(&clip)?)?,
352 Self::Memory(held, kept) => (*held, *kept) = (clip.text(), Some(clip)),352 Self::Memory(held, kept) => (*held, *kept) = (clip.text(), Some(clip)),
353 }353 }
354 Ok(())354 Ok(())
crates/snowbound/src/paste.rs+73-7
...@@ -8,26 +8,85 @@ use std::{...@@ -8,26 +8,85 @@ use std::{
8 path::{Path, PathBuf},8 path::{Path, PathBuf},
9};9};
1010
11/// Every format Copy puts on the clipboard for one selection, which each platform's clipboard11/// Copy offers text and HTML, or a picture as PNG, beside Snowbound's lossless clip.
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.
16pub(crate) struct Copied {12pub(crate) struct Copied {
17 pub text: String,13 pub text: String,
18 /// A whole page, the copy between `<!--StartFragment-->` and `<!--EndFragment-->`.14 /// A whole page, the copy between `<!--StartFragment-->` and `<!--EndFragment-->`.
19 pub html: String,15 pub html: String,
20 pub clip: String,16 pub clip: String,
17 pub picture: Option<Vec<u8>>,
21}18}
2219
23impl Copied {20impl Copied {
24 pub(crate) fn new(clip: &Clip) -> Self {21 pub(crate) fn new(clip: &Clip) -> Result<Self, Box<dyn Error>> {
25 Self {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 {
26 text: clip.text(),42 text: clip.text(),
27 html: clip.html(),43 html: clip.html(),
28 clip: clip.encode(),44 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 }
29 }63 }
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());
30 }69 }
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))
31}90}
3291
33/// `html` behind the header Windows' `HTML Format` begins with, which gives the byte offsets92/// `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...@@ -421,6 +480,13 @@ pub(crate) fn png(size: [u32; 2], rgba: &[u8]) -> Result<Vec<u8>, png::EncodingE
421mod tests {480mod tests {
422 use super::*;481 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
424 fn png_at(pixels_per_metre: Option<u32>) -> Vec<u8> {490 fn png_at(pixels_per_metre: Option<u32>) -> Vec<u8> {
425 let mut bytes = Vec::new();491 let mut bytes = Vec::new();
426 let mut encoder = png::Encoder::new(&mut bytes, 200, 100);492 let mut encoder = png::Encoder::new(&mut bytes, 200, 100);
crates/snowbound/src/web.rs+9-2
...@@ -44,6 +44,8 @@ extern "C" {...@@ -44,6 +44,8 @@ extern "C" {
44 fn place_input(x: f32, y: f32, height: f32, text: bool);44 fn place_input(x: f32, y: f32, height: f32, text: bool);
45 #[wasm_bindgen(js_name = writeClipboard)]45 #[wasm_bindgen(js_name = writeClipboard)]
46 fn write_clipboard(text: &str);46 fn write_clipboard(text: &str);
47 #[wasm_bindgen(js_name = copyPicture)]
48 fn copy_picture(bytes: &[u8]);
47 /// Hands `bytes` to the browser to save as `name`, of MIME type `kind`.49 /// Hands `bytes` to the browser to save as `name`, of MIME type `kind`.
48 pub fn download(name: &str, bytes: &[u8], kind: &str);50 pub fn download(name: &str, bytes: &[u8], kind: &str);
49 #[wasm_bindgen(js_name = pickFiles)]51 #[wasm_bindgen(js_name = pickFiles)]
...@@ -523,9 +525,14 @@ impl Clipboard {...@@ -523,9 +525,14 @@ impl Clipboard {
523 Ok(())525 Ok(())
524 }526 }
525527
526 /// A copy's text, the format every browser's clipboard takes.528 /// Text or a PNG picture on the browser's clipboard.
527 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), Box<dyn Error>> {529 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 }
529 }536 }
530537
531 pub fn get_clip(&mut self) -> Option<String> {538 pub fn get_clip(&mut self) -> Option<String> {
crates/snowbound/src/windows.rs+42-15
...@@ -1112,22 +1112,49 @@ impl Clipboard {...@@ -1112,22 +1112,49 @@ impl Clipboard {
1112 self.0.set_text(text)1112 self.0.set_text(text)
1113 }1113 }
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.
1116 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), &'static str> {1116 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};1117 use windows_sys::Win32::System::{
1118 let text: Vec<u8> = copied1118 DataExchange as clip, Memory,
1119 .text1119 Ole::{CF_DIB, CF_UNICODETEXT},
1120 .replace('\n', "\r\n")1120 };
1121 .encode_utf16()1121 let formats = if let Some(picture) = &copied.picture {
1122 .chain([0])1122 let (size, mut rgba) =
1123 .flat_map(u16::to_le_bytes)1123 crate::paste::pixels(picture).map_err(|_| "The picture couldn't be decoded")?;
1124 .collect();1124 for pixel in rgba.chunks_exact_mut(4) {
1125 let html = crate::paste::cf_html(&copied.html);1125 pixel.swap(0, 2);
1126 let formats = [1126 }
1127 (u32::from(CF_UNICODETEXT), text),1127 let header = [
1128 (registered("HTML Format"), [html.as_bytes(), &[0]].concat()),1128 40_u32.to_le_bytes().as_slice(),
1129 (registered(CLIP), [copied.clip.as_bytes(), &[0]].concat()),1129 (size[0] as i32).to_le_bytes().as_slice(),
1130 ];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 };
1131 let _open = Open::new(self.1)?;1158 let _open = Open::new(self.1)?;
1132 if unsafe { clip::EmptyClipboard() } == 0 {1159 if unsafe { clip::EmptyClipboard() } == 0 {
1133 return Err("The clipboard couldn't be emptied");1160 return Err("The clipboard couldn't be emptied");
crates/snowbound/web/glue.js+5
...@@ -443,6 +443,11 @@ export function writeClipboard(text) {...@@ -443,6 +443,11 @@ export function writeClipboard(text) {
443 navigator.clipboard.writeText(text).catch((error) => console.warn("Copy", error));443 navigator.clipboard.writeText(text).catch((error) => console.warn("Copy", error));
444}444}
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
446export function download(name, bytes, kind) {451export function download(name, bytes, kind) {
447 const url = URL.createObjectURL(new Blob([bytes], { type: kind }));452 const url = URL.createObjectURL(new Blob([bytes], { type: kind }));
448 const link = Object.assign(document.createElement("a"), { href: url, download: name });453 const link = Object.assign(document.createElement("a"), { href: url, download: name });