authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-24 02:36:46-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:21-07:00
log1916e882c31374800ac83f9c8bc6a362c71320ca
treee85669f0ec2ea50815facb97af57c376679688d0
parent4527a055161bfd2ca5f5beb5d38f2c6cceb675ad
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: delete a selected picture and leave it with the arrow keys as OneNote does

Delete or Backspace removes the selected picture as one undo step. Left and Right leave it for the end of the text outline before it in page order or the start of the one after, as OneNote 2010 does; OneNote has no arrow nudge for pictures. Its Up and Down wrap the picture into a new outline with a caption below, which needs pictures inside outlines and is not modelled. Assisted-by: claude-opus-5.5

2 files changed, 188 insertions(+), 0 deletions(-)

crates/canvas/src/editor.rs+163
...@@ -624,6 +624,11 @@ enum History {...@@ -624,6 +624,11 @@ enum History {
624 image: ExGuid,624 image: ExGuid,
625 layout: onestore::document::Layout,625 layout: onestore::document::Layout,
626 },626 },
627 /// Restores the picture at `index` in paint order, or removes the one there when `None`.
628 Picture {
629 index: usize,
630 image: Option<Box<onestore::page::Image>>,
631 },
627 Remove {632 Remove {
628 outline: onestore::ExGuid,633 outline: onestore::ExGuid,
629 focus: RestoreFocus,634 focus: RestoreFocus,
...@@ -1336,6 +1341,62 @@ impl CanvasEditor {...@@ -1336,6 +1341,62 @@ impl CanvasEditor {
1336 Ok(())1341 Ok(())
1337 }1342 }
13381343
1344 /// Leaves a picture as OneNote's Left and Right arrows do: the caret goes to the end of the
1345 /// text outline before it in page order, or to the start of the one after. False when there
1346 /// is none.
1347 pub fn step_from_image(
1348 &mut self,
1349 engine: &mut TextEngine,
1350 id: ExGuid,
1351 forward: bool,
1352 ) -> Result<bool, EditorError> {
1353 let index = self
1354 .objects
1355 .iter()
1356 .position(|object| matches!(object, page::Content::Image(image) if image.id == id))
1357 .ok_or(EditError::InvalidRange)?;
1358 let editable = |object: &page::Content| match object {
1359 page::Content::Editable(outline) => Some(*outline),
1360 _ => None,
1361 };
1362 let outline = if forward {
1363 self.objects[index + 1..].iter().find_map(editable)
1364 } else {
1365 self.objects[..index].iter().rev().find_map(editable)
1366 };
1367 let Some(outline) = outline else {
1368 return Ok(false);
1369 };
1370 self.focus_outline(outline)?;
1371 let edge = if forward {
1372 Movement::DocumentStart
1373 } else {
1374 Movement::DocumentEnd
1375 };
1376 self.move_selection(engine, edge, false)?;
1377 Ok(true)
1378 }
1379
1380 pub fn remove_image(&mut self, id: ExGuid) -> Result<(), EditError> {
1381 let index = self
1382 .objects
1383 .iter()
1384 .position(|object| {
1385 matches!(object, page::Content::Image(image) if image.id == id && !image.background)
1386 })
1387 .ok_or(EditError::InvalidRange)?;
1388 self.finish_composition();
1389 let page::Content::Image(image) = self.objects.remove(index) else {
1390 unreachable!()
1391 };
1392 self.undo.push(History::Picture {
1393 index,
1394 image: Some(Box::new(image)),
1395 });
1396 self.redo.clear();
1397 Ok(())
1398 }
1399
1339 pub fn selection(&self) -> Selection {1400 pub fn selection(&self) -> Selection {
1340 self.active_outline().selection1401 self.active_outline().selection
1341 }1402 }
...@@ -2315,6 +2376,13 @@ impl CanvasEditor {...@@ -2315,6 +2376,13 @@ impl CanvasEditor {
2315 self.outlines.iter().any(|item| item.id == *outline)2376 self.outlines.iter().any(|item| item.id == *outline)
2316 }2377 }
2317 History::Image { image, .. } => self.image(*image).is_some(),2378 History::Image { image, .. } => self.image(*image).is_some(),
2379 History::Picture {
2380 index,
2381 image: Some(_),
2382 } => *index <= self.objects.len(),
2383 History::Picture { index, image: None } => {
2384 matches!(self.objects.get(*index), Some(page::Content::Image(_)))
2385 }
2318 History::Remove { outline, focus } => {2386 History::Remove { outline, focus } => {
2319 self.outlines.iter().any(|item| item.id == *outline)2387 self.outlines.iter().any(|item| item.id == *outline)
2320 && match focus {2388 && match focus {
...@@ -2466,6 +2534,19 @@ impl CanvasEditor {...@@ -2466,6 +2534,19 @@ impl CanvasEditor {
2466 image,2534 image,
2467 layout: std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout),2535 layout: std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout),
2468 },2536 },
2537 History::Picture { index, image } => History::Picture {
2538 index,
2539 image: match image {
2540 Some(image) => {
2541 self.objects.insert(index, page::Content::Image(*image));
2542 None
2543 }
2544 None => match self.objects.remove(index) {
2545 page::Content::Image(image) => Some(Box::new(image)),
2546 _ => unreachable!(),
2547 },
2548 },
2549 },
2469 History::Remove { outline, focus } => {2550 History::Remove { outline, focus } => {
2470 let index = self2551 let index = self
2471 .outlines2552 .outlines
...@@ -4479,6 +4560,88 @@ mod tests {...@@ -4479,6 +4560,88 @@ mod tests {
4479 assert!(editor.redo(&mut engine).unwrap());4560 assert!(editor.redo(&mut engine).unwrap());
4480 assert!(editor.redo(&mut engine).unwrap());4561 assert!(editor.redo(&mut engine).unwrap());
4481 assert_eq!(stored(&editor), resized);4562 assert_eq!(stored(&editor), resized);
4563
4564 let ids = |editor: &CanvasEditor| {
4565 editor
4566 .page()
4567 .unwrap()
4568 .objects
4569 .iter()
4570 .map(PageObject::id)
4571 .collect::<Vec<_>>()
4572 };
4573 let before = ids(&editor);
4574 assert_eq!(
4575 editor.remove_image(background_id),
4576 Err(EditError::InvalidRange)
4577 );
4578 editor.remove_image(id).unwrap();
4579 assert!(editor.image_placement(id).is_none());
4580 assert_eq!(ids(&editor), [background_id]);
4581 assert!(editor.undo(&mut engine).unwrap());
4582 assert_eq!(ids(&editor), before);
4583 assert_eq!(stored(&editor), resized);
4584 assert!(editor.redo(&mut engine).unwrap());
4585 assert_eq!(ids(&editor), [background_id]);
4586 }
4587
4588 #[test]
4589 fn arrows_leave_a_picture_for_the_neighbouring_outlines_in_page_order() {
4590 use onestore::page::{Image, Page, PageObject};
4591 let mut engine = TextEngine::default();
4592 let outline = |engine: &mut TextEngine, text: &str, x| {
4593 PageObject::Outline(
4594 TextOutline::new(
4595 engine,
4596 TextDocument::new(vec![Paragraph::new(text.into(), Default::default())])
4597 .unwrap(),
4598 120.0,
4599 [x, 0.0],
4600 )
4601 .unwrap()
4602 .snapshot(),
4603 )
4604 };
4605 let picture = Image {
4606 size: None,
4607 id: onestore::page::text::new_id().unwrap(),
4608 layout: onestore::document::Layout {
4609 x: Some(200.0),
4610 y: Some(0.0),
4611 max_width: Some(40.0),
4612 max_height: Some(40.0),
4613 ..Default::default()
4614 },
4615 bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())),
4616 alt: None,
4617 background: false,
4618 };
4619 let id = picture.id;
4620 let objects = vec![
4621 outline(&mut engine, "Before", 0.0),
4622 PageObject::Image(picture),
4623 outline(&mut engine, "After", 300.0),
4624 ];
4625 let [before, after] = [0, 2].map(|index| objects[index].id());
4626 let mut editor = CanvasEditor::from_page(
4627 Page {
4628 title: String::new(),
4629 identity: None,
4630 created: None,
4631 margin_origin: [36.0, 14.4],
4632 definitions: BTreeMap::new(),
4633 objects,
4634 },
4635 &mut engine,
4636 )
4637 .unwrap();
4638 let caret = |paragraph, offset| [TextPosition { paragraph, offset }; 2];
4639 assert!(editor.step_from_image(&mut engine, id, false).unwrap());
4640 assert_eq!(editor.active_outline().id, before);
4641 assert_eq!(editor.selection().positions, caret(0, 6));
4642 assert!(editor.step_from_image(&mut engine, id, true).unwrap());
4643 assert_eq!(editor.active_outline().id, after);
4644 assert_eq!(editor.selection().positions, caret(0, 0));
4482 }4645 }
44834646
4484 #[test]4647 #[test]
crates/snowbound/src/main.rs+25
...@@ -889,6 +889,26 @@ impl State {...@@ -889,6 +889,26 @@ impl State {
889 let shift = self.modifiers.shift_key();889 let shift = self.modifiers.shift_key();
890 let command = self.modifiers.super_key();890 let command = self.modifiers.super_key();
891 let option = self.modifiers.alt_key();891 let option = self.modifiers.alt_key();
892 if let Some(ObjectFocus::Image(id)) = self.object_focus
893 && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete))
894 {
895 self.editor.remove_image(id)?;
896 self.set_object_focus(None);
897 return self.changed();
898 }
899 if let Some(ObjectFocus::Image(id)) = self.object_focus
900 && !(shift || command || option || self.modifiers.control_key())
901 && let Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowRight) = key
902 && self.editor.step_from_image(
903 &mut self.engine,
904 id,
905 key == &Key::Named(NamedKey::ArrowRight),
906 )?
907 {
908 self.set_object_focus(None);
909 self.reveal_focus()?;
910 return self.changed();
911 }
892 if let Some(focus) = self.object_focus {912 if let Some(focus) = self.object_focus {
893 let undo = matches!(focus, ObjectFocus::Image(_))913 let undo = matches!(focus, ObjectFocus::Image(_))
894 && command914 && command
...@@ -1020,6 +1040,11 @@ impl State {...@@ -1020,6 +1040,11 @@ impl State {
1020 } else {1040 } else {
1021 self.editor.undo(&mut self.engine)?;1041 self.editor.undo(&mut self.engine)?;
1022 }1042 }
1043 if let Some(ObjectFocus::Image(id)) = self.object_focus
1044 && self.editor.image_placement(id).is_none()
1045 {
1046 self.set_object_focus(None);
1047 }
1023 }1048 }
1024 "c" | "x" => {1049 "c" | "x" => {
1025 let [anchor, focus] = self.editor.selection().positions;1050 let [anchor, focus] = self.editor.selection().positions;