| author | |
| committer | |
| log | fa491ad643baacd44b828f5da2fc401bf098334a |
| tree | 0ade54ebeb9310e6f4adc2c8ce85ebfad6f76121 |
| parent | e555f7cca8a01b01a44cb5a6689d4e8f7072491a |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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-sol14 files changed, 561 insertions(+), 89 deletions(-)
crates/canvas/src/editor.rs+9-8| ... | ... | @@ -2069,14 +2069,15 @@ impl CanvasEditor { |
| 2069 | 2069 | |
| 2070 | 2070 | /// The address picture `id` links to, in an outline or on the page. |
| 2071 | 2071 | 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 | }) | |
| 2080 | 2081 | } |
| 2081 | 2082 | |
| 2082 | 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 | 56 | } |
| 57 | 57 | |
| 58 | 58 | impl 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 | 75 | /// What Copy takes: the selection's paragraphs with the first and last cut to it, or the |
| 60 | 76 | /// page selection's outlines top to bottom with an empty paragraph between, as OneNote |
| 61 | 77 | /// 2010 copies them. None where nothing shows. |
| ... | ... | @@ -156,17 +172,10 @@ impl CanvasEditor { |
| 156 | 172 | let added = leaves(&nodes, None).count(); |
| 157 | 173 | edit.replacement.splice(1..=count, nodes); |
| 158 | 174 | 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, | |
| 170 | 179 | }; |
| 171 | 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 | 316 | Ok(()) |
| 317 | 317 | } |
| 318 | 318 | |
| 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. | |
| 321 | 320 | pub(crate) fn follow( |
| 322 | 321 | &mut self, |
| 323 | 322 | editor: &mut CanvasEditor, |
| ... | ... | @@ -332,7 +331,20 @@ impl PageScene { |
| 332 | 331 | } |
| 333 | 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 | 348 | return Ok(()); |
| 337 | 349 | } |
| 338 | 350 | self.refresh(editor, engine) |
| ... | ... | @@ -400,14 +412,15 @@ impl PageScene { |
| 400 | 412 | payloads.push((source.id, [source.preview.as_ref(), None])) |
| 401 | 413 | } |
| 402 | 414 | 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); | |
| 411 | 424 | } |
| 412 | 425 | } |
| 413 | 426 | let mut pictures = std::collections::BTreeMap::new(); |
| ... | ... | @@ -515,14 +528,15 @@ impl PageScene { |
| 515 | 528 | outline(layout, origin, &mut rects); |
| 516 | 529 | } |
| 517 | 530 | } |
| 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); | |
| 526 | 540 | } |
| 527 | 541 | } |
| 528 | 542 | rects |
crates/canvas/src/interaction/mod.rs+58-14| ... | ... | @@ -79,12 +79,13 @@ pub struct Context { |
| 79 | 79 | /// The address of the link at the caret. |
| 80 | 80 | pub link: Option<String>, |
| 81 | 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 | 83 | pub selected: bool, |
| 84 | 84 | /// The paragraph at the caret, which Copy Link to Paragraph names. |
| 85 | 85 | pub paragraph: Option<onestore::ExGuid>, |
| 86 | 86 | /// The file the press selected, which Open and Save As take. |
| 87 | 87 | pub attachment: Option<onestore::page::Attachment>, |
| 88 | pub image: Option<onestore::page::Image>, | |
| 88 | 89 | /// The marked word the press landed on, which the menu offers corrections for. |
| 89 | 90 | pub spelling: Option<Correction>, |
| 90 | 91 | } |
| ... | ... | @@ -640,9 +641,12 @@ impl PageView { |
| 640 | 641 | |
| 641 | 642 | /// Page coordinates of a focused object. |
| 642 | 643 | fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] { |
| 643 | let (scene, offset) = self.scene.as_ref().unwrap(); | |
| 644 | 644 | let rect = match focus { |
| 645 | ObjectFocus::ReadOnly(index) => scene | |
| 645 | ObjectFocus::ReadOnly(index) => self | |
| 646 | .scene | |
| 647 | .as_ref() | |
| 648 | .unwrap() | |
| 649 | .0 | |
| 646 | 650 | .read_only(Some(&self.editor)) |
| 647 | 651 | .nth(index) |
| 648 | 652 | .unwrap() |
| ... | ... | @@ -653,7 +657,10 @@ impl PageView { |
| 653 | 657 | } |
| 654 | 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 | } |
| 658 | 665 | |
| 659 | 666 | /// Keeps the view in bounds and restarts the caret blink after a change. |
| ... | ... | @@ -1570,6 +1577,22 @@ impl PageView { |
| 1570 | 1577 | |
| 1571 | 1578 | /// Copy, or Cut with `cut`: [`CanvasEditor::clip`] goes to the clipboard. |
| 1572 | 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 | 1596 | let page = self.editor.whole() == Some(Whole::Page); |
| 1574 | 1597 | let Some(clip) = self.editor.clip()? else { |
| 1575 | 1598 | return Ok(Response::default()); |
| ... | ... | @@ -1585,20 +1608,33 @@ impl PageView { |
| 1585 | 1608 | }) |
| 1586 | 1609 | } |
| 1587 | 1610 | |
| 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 | 1623 | /// 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. | |
| 1591 | 1626 | pub fn context(&mut self) -> Result<Option<(Response, Context)>> { |
| 1592 | 1627 | let point = self.viewport.document_point(self.pointer); |
| 1593 | 1628 | let (id, point) = match self.hit_test(point) { |
| 1594 | 1629 | 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) | |
| 1601 | 1635 | }; |
| 1636 | self.set_object_focus(Some(focus)); | |
| 1637 | let context = self.caret_context().unwrap_or_default(); | |
| 1602 | 1638 | return Ok(Some((self.changed()?, context))); |
| 1603 | 1639 | } |
| 1604 | 1640 | _ => return Ok(None), |
| ... | ... | @@ -1622,9 +1658,16 @@ impl PageView { |
| 1622 | 1658 | Ok(Some((self.changed()?, context))) |
| 1623 | 1659 | } |
| 1624 | 1660 | |
| 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 | |
| 1626 | 1662 | /// takes one. |
| 1627 | 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 | 1671 | if let Some(ObjectFocus::File(id)) = self.object_focus { |
| 1629 | 1672 | return Some(Context { |
| 1630 | 1673 | attachment: self.editor.attachment(id).cloned(), |
| ... | ... | @@ -1649,6 +1692,7 @@ impl PageView { |
| 1649 | 1692 | .leaf(anchor.min(focus).paragraph) |
| 1650 | 1693 | .map(|(_, _, node)| node.id), |
| 1651 | 1694 | attachment: None, |
| 1695 | image: None, | |
| 1652 | 1696 | spelling: self.selected_correction(), |
| 1653 | 1697 | }) |
| 1654 | 1698 | } |
| ... | ... | @@ -1991,7 +2035,7 @@ impl PageView { |
| 1991 | 2035 | self.changed() |
| 1992 | 2036 | } |
| 1993 | 2037 | |
| 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 | 2039 | fn follow_pictures(&mut self) -> Result<()> { |
| 1996 | 2040 | if let Some((scene, _)) = &mut self.scene { |
| 1997 | 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 | 1593 | (view, id) |
| 1594 | 1594 | } |
| 1595 | 1595 | |
| 1596 | #[test] | |
| 1597 | fn 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] | |
| 1686 | fn 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 | 1740 | #[test] |
| 1597 | 1741 | fn a_page_outline_emptied_by_backspace_still_draws() { |
| 1598 | 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 | 47 | /// How long a paste waits on the app that copied. |
| 48 | 48 | const PATIENCE: Duration = Duration::from_secs(2); |
| 49 | 49 | |
| 50 | /// The HTML and Snowbound's own format of this process's last copy through X11. | |
| 51 | static LAST_COPY: Mutex<Option<(String, String)>> = Mutex::new(None); | |
| 50 | enum CopyFormat { | |
| 51 | Html(String), | |
| 52 | Picture(arboard::ImageData<'static>), | |
| 53 | } | |
| 54 | ||
| 55 | static LAST_COPY: Mutex<Option<(CopyFormat, String)>> = Mutex::new(None); | |
| 52 | 56 | |
| 53 | 57 | pub enum Clipboard { |
| 54 | 58 | Wayland(Box<Wayland>), |
| ... | ... | @@ -82,28 +86,61 @@ impl Clipboard { |
| 82 | 86 | pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), Box<dyn Error>> { |
| 83 | 87 | match self { |
| 84 | 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 | 95 | let text = TEXT.map(|kind| (kind, copied.text.clone().into_bytes())); |
| 86 | 96 | let rich = [("text/html", &copied.html), (CLIP, &copied.clip)] |
| 87 | 97 | .map(|(kind, bytes)| (kind, bytes.clone().into_bytes())); |
| 88 | 98 | wayland.copy(text.into_iter().chain(rich).collect()) |
| 89 | 99 | } |
| 90 | 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 | 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 | 116 | Ok(()) |
| 94 | 117 | } |
| 95 | 118 | } |
| 96 | 119 | } |
| 97 | 120 | |
| 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. | |
| 100 | 122 | pub fn get_clip(&mut self) -> Option<String> { |
| 101 | 123 | if let Self::Wayland(wayland) = self { |
| 102 | 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 | 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 | 144 | _ => None, |
| 108 | 145 | } |
| 109 | 146 | } |
crates/snowbound/src/commands.rs+7-1| ... | ... | @@ -1122,7 +1122,13 @@ impl State { |
| 1122 | 1122 | // Edit commands act on a focused field instead of the page. |
| 1123 | 1123 | let field = self.ui.focused_field().is_some(); |
| 1124 | 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 | 1132 | let enabled = |enabled| Status { |
| 1127 | 1133 | enabled, |
| 1128 | 1134 | checked: None, |
crates/snowbound/src/link.rs+92-2| ... | ... | @@ -250,7 +250,7 @@ impl State { |
| 250 | 250 | Ok(()) |
| 251 | 251 | } |
| 252 | 252 | |
| 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 | 254 | pub(crate) fn open_text_menu(&mut self) -> Result<(), Box<dyn Error>> { |
| 255 | 255 | let Some((response, context)) = self.view.context()? else { |
| 256 | 256 | return Ok(()); |
| ... | ... | @@ -263,7 +263,7 @@ impl State { |
| 263 | 263 | } |
| 264 | 264 | |
| 265 | 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 | 267 | pub(crate) fn text_menu(&mut self) -> Result<(), Box<dyn Error>> { |
| 268 | 268 | let Some((context, point)) = &self.text_menu else { |
| 269 | 269 | return Ok(()); |
| ... | ... | @@ -371,6 +371,53 @@ impl State { |
| 371 | 371 | }; |
| 372 | 372 | let format = self.format_state(); |
| 373 | 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 | 421 | // Paste stands for any edit to the text here. |
| 375 | 422 | let (fixed, unlinkable) = (disabled(commands::Id::Paste), disabled(commands::Id::Link)); |
| 376 | 423 | if context.attachment.is_some() { |
| ... | ... | @@ -471,6 +518,49 @@ impl State { |
| 471 | 518 | context: Context, |
| 472 | 519 | text: &str, |
| 473 | 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 | 564 | if let Some(file) = &context.attachment { |
| 475 | 565 | return match text { |
| 476 | 566 | "Open" => self.open_attachment(file), |
crates/snowbound/src/macos.rs+24-2| ... | ... | @@ -260,6 +260,17 @@ impl Clipboard { |
| 260 | 260 | pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), &'static str> { |
| 261 | 261 | let set = unsafe { |
| 262 | 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 | 275 | (TEXT, &copied.text), |
| 265 | 276 | ("public.html", &copied.html), |
| ... | ... | @@ -368,8 +379,8 @@ unsafe fn ns_data_bytes(data: &AnyObject) -> Vec<u8> { |
| 368 | 379 | if length == 0 { |
| 369 | 380 | return Vec::new(); |
| 370 | 381 | } |
| 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() | |
| 373 | 384 | } |
| 374 | 385 | } |
| 375 | 386 | |
| ... | ... | @@ -1748,12 +1759,23 @@ mod tests { |
| 1748 | 1759 | let copied = crate::paste::Copied { |
| 1749 | 1760 | text: "Copied".into(), |
| 1750 | 1761 | html: "<p>Copied</p>".into(), |
| 1762 | picture: None, | |
| 1751 | 1763 | clip: "{}".into(), |
| 1752 | 1764 | }; |
| 1753 | 1765 | clipboard.set(&copied).unwrap(); |
| 1754 | 1766 | assert_eq!(clipboard.get_text().unwrap(), "Copied"); |
| 1755 | 1767 | assert_eq!(clipboard.get_html().as_deref(), Some("<p>Copied</p>")); |
| 1756 | 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 | 1779 | clipboard.set_text("Plain".into()).unwrap(); |
| 1758 | 1780 | assert_eq!(clipboard.get_clip(), None); |
| 1759 | 1781 | unsafe { msg_send![&board, releaseGlobally] } |
crates/snowbound/src/main.rs+1-1| ... | ... | @@ -348,7 +348,7 @@ impl Clipboard { |
| 348 | 348 | |
| 349 | 349 | fn set(&mut self, clip: Clip) -> Result<(), Box<dyn Error>> { |
| 350 | 350 | match self { |
| 351 | Self::System(clipboard) => clipboard.set(&paste::Copied::new(&clip))?, | |
| 351 | Self::System(clipboard) => clipboard.set(&paste::Copied::new(&clip)?)?, | |
| 352 | 352 | Self::Memory(held, kept) => (*held, *kept) = (clip.text(), Some(clip)), |
| 353 | 353 | } |
| 354 | 354 | Ok(()) |
crates/snowbound/src/paste.rs+73-7| ... | ... | @@ -8,26 +8,85 @@ use std::{ |
| 8 | 8 | path::{Path, PathBuf}, |
| 9 | 9 | }; |
| 10 | 10 | |
| 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. | |
| 16 | 12 | pub(crate) struct Copied { |
| 17 | 13 | pub text: String, |
| 18 | 14 | /// A whole page, the copy between `<!--StartFragment-->` and `<!--EndFragment-->`. |
| 19 | 15 | pub html: String, |
| 20 | 16 | pub clip: String, |
| 17 | pub picture: Option<Vec<u8>>, | |
| 21 | 18 | } |
| 22 | 19 | |
| 23 | 20 | impl 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 { | |
| 26 | 42 | text: clip.text(), |
| 27 | 43 | html: clip.html(), |
| 28 | 44 | clip: clip.encode(), |
| 45 | picture, | |
| 46 | }) | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | pub(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 | } |
| 32 | 91 | |
| 33 | 92 | /// `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 | 480 | mod tests { |
| 422 | 481 | use super::*; |
| 423 | 482 | |
| 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 | 490 | fn png_at(pixels_per_metre: Option<u32>) -> Vec<u8> { |
| 425 | 491 | let mut bytes = Vec::new(); |
| 426 | 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 | 44 | fn place_input(x: f32, y: f32, height: f32, text: bool); |
| 45 | 45 | #[wasm_bindgen(js_name = writeClipboard)] |
| 46 | 46 | fn write_clipboard(text: &str); |
| 47 | #[wasm_bindgen(js_name = copyPicture)] | |
| 48 | fn copy_picture(bytes: &[u8]); | |
| 47 | 49 | /// Hands `bytes` to the browser to save as `name`, of MIME type `kind`. |
| 48 | 50 | pub fn download(name: &str, bytes: &[u8], kind: &str); |
| 49 | 51 | #[wasm_bindgen(js_name = pickFiles)] |
| ... | ... | @@ -523,9 +525,14 @@ impl Clipboard { |
| 523 | 525 | Ok(()) |
| 524 | 526 | } |
| 525 | 527 | |
| 526 | /// A copy's text, the format every browser's clipboard takes. | |
| 528 | /// Text or a PNG picture on the browser's clipboard. | |
| 527 | 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 | } |
| 530 | 537 | |
| 531 | 538 | pub fn get_clip(&mut self) -> Option<String> { |
crates/snowbound/src/windows.rs+42-15| ... | ... | @@ -1112,22 +1112,49 @@ impl Clipboard { |
| 1112 | 1112 | self.0.set_text(text) |
| 1113 | 1113 | } |
| 1114 | 1114 | |
| 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 | 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}; | |
| 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 | }; | |
| 1131 | 1158 | let _open = Open::new(self.1)?; |
| 1132 | 1159 | if unsafe { clip::EmptyClipboard() } == 0 { |
| 1133 | 1160 | return Err("The clipboard couldn't be emptied"); |
crates/snowbound/web/glue.js+5| ... | ... | @@ -443,6 +443,11 @@ export function writeClipboard(text) { |
| 443 | 443 | navigator.clipboard.writeText(text).catch((error) => console.warn("Copy", error)); |
| 444 | 444 | } |
| 445 | 445 | |
| 446 | export 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 | ||
| 446 | 451 | export function download(name, bytes, kind) { |
| 447 | 452 | const url = URL.createObjectURL(new Blob([bytes], { type: kind })); |
| 448 | 453 | const link = Object.assign(document.createElement("a"), { href: url, download: name }); |