| 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,14 +2069,15 @@ impl CanvasEditor { |
| 2069 | 2069 | ||
| 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 | } |
| 2081 | 2082 | ||
| 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 | } |
| 57 | 57 | ||
| 58 | impl CanvasEditor { | 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 | /// What Copy takes: the selection's paragraphs with the first and last cut to it, or the | 75 | /// 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 OneNote | 76 | /// 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 | } |
| 318 | 318 | ||
| 319 | /// Follows `editor` after an edit or undo brought page-level pictures the scene has not | 319 | /// 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 | rects | 542 | 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 { |
| 640 | 641 | ||
| 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) => scene | 645 | 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 | } |
| 658 | 665 | ||
| 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 { |
| 1570 | 1577 | ||
| 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 | } |
| 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 | /// A secondary press at the pointer: text there takes the caret unless it lies in the | 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. | 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 | } |
| 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 | /// 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 | } |
| 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 | 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 | } |
| 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 | #[test] | 1740 | #[test] |
| 1597 | fn a_page_outline_emptied_by_backspace_still_draws() { | 1741 | fn 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. |
| 48 | const PATIENCE: Duration = Duration::from_secs(2); | 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. | 50 | enum CopyFormat { |
| 51 | static LAST_COPY: Mutex<Option<(String, String)>> = Mutex::new(None); | 51 | Html(String), |
| 52 | Picture(arboard::ImageData<'static>), | ||
| 53 | } | ||
| 54 | |||
| 55 | static LAST_COPY: Mutex<Option<(CopyFormat, String)>> = Mutex::new(None); | ||
| 52 | 56 | ||
| 53 | pub enum Clipboard { | 57 | pub 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 | } |
| 97 | 120 | ||
| 98 | /// What Snowbound itself copied, if it did: on X11, which arboard offers no format of | 121 | /// 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 | } |
| 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 | 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 | } |
| 264 | 264 | ||
| 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 | } |
| 375 | 386 | ||
| ... | @@ -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 { |
| 348 | 348 | ||
| 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 | }; |
| 10 | 10 | ||
| 11 | /// Every format Copy puts on the clipboard for one selection, which each platform's clipboard | 11 | /// 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. | ||
| 16 | pub(crate) struct Copied { | 12 | pub(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 | } |
| 22 | 19 | ||
| 23 | impl Copied { | 20 | impl 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 | |||
| 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 | /// `html` behind the header Windows' `HTML Format` begins with, which gives the byte offsets | 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,6 +480,13 @@ pub(crate) fn png(size: [u32; 2], rgba: &[u8]) -> Result<Vec<u8>, png::EncodingE |
| 421 | mod tests { | 480 | mod tests { |
| 422 | use super::*; | 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 | 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 | } |
| 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 | 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 | } |
| 530 | 537 | ||
| 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 | } |
| 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 | 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> = copied | 1118 | DataExchange as clip, Memory, |
| 1119 | .text | 1119 | 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 | } |
| 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 | export function download(name, bytes, kind) { | 451 | export 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 }); |