authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-24 11:19:41-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:21-07:00
log7b577f2383c4ed4a5d5a84a8b564d088198a773e
tree992e2d4188c36394bdc46735cbfaad4c1893f2e8
parentb822df2242bfd846f6475e49af173e4736187b4b
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: select, resize and delete pictures inside outlines

A picture in an outline's flow took no clicks. It now selects like a page picture: its handles resize it and Delete removes it, both through the outline's text history so undo restores the layout around it, while a drag leaves it in place. Left and Right put the caret in the text beside it. Assisted-by: claude-opus-5.5

4 files changed, 276 insertions(+), 35 deletions(-)

crates/canvas/src/editor.rs+176-25
......@@ -1,6 +1,6 @@
11use crate::{
22 date::PageDate,
3 document::{DocumentEdit, TextDocument, TextPosition, edited_nodes, leaves},
3 document::{DocumentEdit, TextDocument, TextPosition, descendants, edited_nodes, leaves},
44 layout::{LayoutError, TextEngine},
55 outline::{
66 Arrange, OutlineLayout, ParagraphLayout, arrange, indentation, outline_layout,
......@@ -1278,11 +1278,60 @@ impl CanvasEditor {
12781278 /// Origin and size of a picture the user can select; OneNote passes clicks through
12791279 /// backgrounds.
12801280 pub fn image_placement(&self, id: ExGuid) -> Option<([f32; 2], [f32; 2])> {
1281 let layout = &self.image(id)?.layout;
1282 Some((
1283 [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)],
1284 [layout.max_width?, layout.max_height?],
1285 ))
1281 if let Some(image) = self.image(id) {
1282 let layout = &image.layout;
1283 return Some((
1284 [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)],
1285 [layout.max_width?, layout.max_height?],
1286 ));
1287 }
1288 let (outline, ..) = self.outline_picture(id)?;
1289 let outline = self.outlines.iter().find(|item| item.id == outline)?;
1290 let [x0, y0, x1, y1] = outline.shaped.objects.iter().find(|o| o.id == id)?.rect;
1291 let origin = outline.origin();
1292 Some(([origin[0] + x0, origin[1] + y0], [x1 - x0, y1 - y0]))
1293 }
1294
1295 /// Whether the picture sits in an outline's flow, where only its size can change.
1296 pub fn image_in_outline(&self, id: ExGuid) -> bool {
1297 self.outline_picture(id).is_some()
1298 }
1299
1300 /// A picture held as a paragraph: its outline, the paragraph's container and index there,
1301 /// and the paragraph.
1302 fn outline_picture(
1303 &self,
1304 id: ExGuid,
1305 ) -> Option<(ExGuid, Option<ExGuid>, usize, &PageParagraph)> {
1306 self.outlines.iter().find_map(|outline| {
1307 descendants(outline.document.nodes(), None).find_map(|(container, index, node)| {
1308 matches!(&node.content, onestore::page::ParagraphContent::Image(image) if image.id == id)
1309 .then_some((outline.id, container, index, node))
1310 })
1311 })
1312 }
1313
1314 /// Replaces a picture paragraph through the outline's text history.
1315 fn edit_outline_picture(
1316 &mut self,
1317 engine: &mut TextEngine,
1318 id: ExGuid,
1319 replacement: Option<PageParagraph>,
1320 ) -> Result<(), EditorError> {
1321 let (outline, container, index, _) =
1322 self.outline_picture(id).ok_or(EditError::InvalidRange)?;
1323 self.focus_outline(outline)?;
1324 let selection = self.selection();
1325 self.commit(
1326 engine,
1327 DocumentEdit {
1328 columns: BTreeMap::new(),
1329 container,
1330 range: index..index + 1,
1331 replacement: replacement.into_iter().collect(),
1332 },
1333 selection,
1334 )
12861335 }
12871336
12881337 fn image(&self, id: ExGuid) -> Option<&onestore::page::Image> {
......@@ -1299,17 +1348,31 @@ impl CanvasEditor {
12991348 })
13001349 }
13011350
1302 /// Moves a picture, and resizes it when `size` differs from the stored size.
1351 /// Moves a picture, and resizes it when `size` differs from the stored size. A picture in
1352 /// an outline's flow keeps its place.
13031353 pub fn place_image(
13041354 &mut self,
1355 engine: &mut TextEngine,
13051356 id: ExGuid,
13061357 origin: [f32; 2],
13071358 size: [f32; 2],
1308 ) -> Result<(), EditError> {
1359 ) -> Result<(), EditorError> {
13091360 if !origin.iter().chain(&size).all(|value| value.is_finite())
13101361 || size.iter().any(|value| *value <= 0.0)
13111362 {
1312 return Err(EditError::InvalidRange);
1363 return Err(EditError::InvalidRange.into());
1364 }
1365 if let Some((.., node)) = self.outline_picture(id) {
1366 let mut node = node.clone();
1367 let onestore::page::ParagraphContent::Image(image) = &mut node.content else {
1368 unreachable!()
1369 };
1370 if crate::outline::image_size(image) == Some(size) {
1371 return Ok(());
1372 }
1373 [image.layout.max_width, image.layout.max_height] = size.map(Some);
1374 image.layout.width_set_by_user = Some(true);
1375 return self.edit_outline_picture(engine, id, Some(node));
13131376 }
13141377 let image = self.image(id).ok_or(EditError::InvalidRange)?;
13151378 let mut layout = image.layout.clone();
......@@ -1340,6 +1403,44 @@ impl CanvasEditor {
13401403 id: ExGuid,
13411404 forward: bool,
13421405 ) -> Result<bool, EditorError> {
1406 if let Some((outline, ..)) = self.outline_picture(id) {
1407 // Within an outline, the caret goes to the text beside the picture.
1408 let source = self
1409 .outlines
1410 .iter()
1411 .find(|item| item.id == outline)
1412 .unwrap();
1413 let mut leaf = 0;
1414 let mut before = None;
1415 let mut after = None;
1416 let mut passed = false;
1417 for (_, _, node) in descendants(source.document.nodes(), None) {
1418 match &node.content {
1419 onestore::page::ParagraphContent::Image(image) if image.id == id => {
1420 passed = true
1421 }
1422 onestore::page::ParagraphContent::Text(text) => {
1423 let end = text.text.utf16_offset(text.text.text().len())?;
1424 if passed {
1425 after = after.or(Some((leaf, 0)));
1426 } else {
1427 before = Some((leaf, end));
1428 }
1429 leaf += 1;
1430 }
1431 _ => {}
1432 }
1433 }
1434 let Some((paragraph, offset)) = (if forward { after } else { before }) else {
1435 return Ok(false);
1436 };
1437 self.focus_outline(outline)?;
1438 self.select(Selection {
1439 positions: [TextPosition { paragraph, offset }; 2],
1440 affinities: [Affinity::Downstream; 2],
1441 })?;
1442 return Ok(true);
1443 }
13431444 let index = self
13441445 .objects
13451446 .iter()
......@@ -1367,7 +1468,10 @@ impl CanvasEditor {
13671468 Ok(true)
13681469 }
13691470
1370 pub fn remove_image(&mut self, id: ExGuid) -> Result<(), EditError> {
1471 pub fn remove_image(&mut self, engine: &mut TextEngine, id: ExGuid) -> Result<(), EditorError> {
1472 if self.outline_picture(id).is_some() {
1473 return self.edit_outline_picture(engine, id, None);
1474 }
13711475 let index = self
13721476 .objects
13731477 .iter()
......@@ -4538,22 +4642,22 @@ mod tests {
45384642 Some(([468.75, 86.4], [333.0, 200.1]))
45394643 );
45404644 assert!(editor.image_placement(background_id).is_none());
4541 assert_eq!(
4542 editor.place_image(background_id, [0.0; 2], [10.0; 2]),
4543 Err(EditError::InvalidRange)
4544 );
4545 assert_eq!(
4546 editor.place_image(id, [0.0; 2], [0.0, 10.0]),
4547 Err(EditError::InvalidRange)
4548 );
4645 assert!(matches!(
4646 editor.place_image(&mut engine, background_id, [0.0; 2], [10.0; 2]),
4647 Err(EditorError::Edit(EditError::InvalidRange))
4648 ));
4649 assert!(matches!(
4650 editor.place_image(&mut engine, id, [0.0; 2], [0.0, 10.0]),
4651 Err(EditorError::Edit(EditError::InvalidRange))
4652 ));
45494653 editor
4550 .place_image(id, [513.75, 104.4], [333.0, 200.1])
4654 .place_image(&mut engine, id, [513.75, 104.4], [333.0, 200.1])
45514655 .unwrap();
45524656 let moved = stored(&editor);
45534657 assert_eq!([moved.x, moved.y], [Some(513.75), Some(104.4)]);
45544658 assert_eq!(moved.width_set_by_user, None);
45554659 editor
4556 .place_image(id, [513.75, 104.4], [281.8, 169.4])
4660 .place_image(&mut engine, id, [513.75, 104.4], [281.8, 169.4])
45574661 .unwrap();
45584662 let resized = stored(&editor);
45594663 assert_eq!(
......@@ -4579,11 +4683,11 @@ mod tests {
45794683 .collect::<Vec<_>>()
45804684 };
45814685 let before = ids(&editor);
4582 assert_eq!(
4583 editor.remove_image(background_id),
4584 Err(EditError::InvalidRange)
4585 );
4586 editor.remove_image(id).unwrap();
4686 assert!(matches!(
4687 editor.remove_image(&mut engine, background_id),
4688 Err(EditorError::Edit(EditError::InvalidRange))
4689 ));
4690 editor.remove_image(&mut engine, id).unwrap();
45874691 assert!(editor.image_placement(id).is_none());
45884692 assert_eq!(ids(&editor), [background_id]);
45894693 assert!(editor.undo(&mut engine).unwrap());
......@@ -4732,6 +4836,53 @@ mod tests {
47324836 assert!(editor.undo(&mut engine).unwrap());
47334837 assert_eq!(editor.active_outline().shaped().paragraphs.len(), 2);
47344838 assert_eq!(editor.active_outline().shaped().objects.len(), 1);
4839
4840 // Selected, it resizes and deletes in place through the outline's history.
4841 assert!(editor.image_in_outline(image.id));
4842 let (origin, size) = editor.image_placement(image.id).unwrap();
4843 assert_eq!(size, [40.0, 30.0]);
4844 editor
4845 .place_image(&mut engine, image.id, [0.0; 2], [80.0, 60.0])
4846 .unwrap();
4847 assert_eq!(
4848 editor.image_placement(image.id),
4849 Some((origin, [80.0, 60.0]))
4850 );
4851 let shaped = editor.active_outline().shaped();
4852 assert!(shaped.paragraphs[1].origin[1] >= shaped.objects[0].bottom);
4853 assert!(editor.step_from_image(&mut engine, image.id, true).unwrap());
4854 assert_eq!(
4855 editor.selection().positions[0],
4856 TextPosition {
4857 paragraph: 1,
4858 offset: 0
4859 }
4860 );
4861 assert!(
4862 editor
4863 .step_from_image(&mut engine, image.id, false)
4864 .unwrap()
4865 );
4866 assert_eq!(
4867 editor.selection().positions[0],
4868 TextPosition {
4869 paragraph: 0,
4870 offset: 6
4871 }
4872 );
4873 editor.remove_image(&mut engine, image.id).unwrap();
4874 assert!(editor.image_placement(image.id).is_none());
4875 assert!(editor.active_outline().shaped().objects.is_empty());
4876 assert!(editor.undo(&mut engine).unwrap());
4877 assert_eq!(
4878 editor.image_placement(image.id),
4879 Some((origin, [80.0, 60.0]))
4880 );
4881 assert!(editor.undo(&mut engine).unwrap());
4882 assert_eq!(
4883 editor.image_placement(image.id),
4884 Some((origin, [40.0, 30.0]))
4885 );
47354886 }
47364887
47374888 #[test]
crates/canvas/src/gpu/page.rs+12-7
......@@ -213,20 +213,25 @@ impl PageScene {
213213 }
214214
215215 /// Pictures, files and handwriting inside an outline whose origin is `origin`.
216 /// `moving` draws one picture at a previewed rectangle, in the same coordinates as `origin`.
216217 pub fn append_outline_objects<'a>(
217218 &'a self,
218219 outline: &'a crate::outline::OutlineLayout,
219220 origin: [f32; 2],
221 moving: Option<(onestore::ExGuid, [f32; 4])>,
220222 primitives: &mut Vec<Primitive<'a>>,
221223 ) {
222224 for object in &outline.objects {
223225 let [x0, y0, x1, y1] = object.rect;
224 let rect = [
225 x0 + origin[0],
226 y0 + origin[1],
227 x1 + origin[0],
228 y1 + origin[1],
229 ];
226 let rect = match moving {
227 Some((id, rect)) if id == object.id => rect,
228 _ => [
229 x0 + origin[0],
230 y0 + origin[1],
231 x1 + origin[0],
232 y1 + origin[1],
233 ],
234 };
230235 if let Some(image) = self.images.get(&object.id) {
231236 primitives.push(Primitive::Image { image, rect });
232237 }
......@@ -551,7 +556,7 @@ impl PageScene {
551556 };
552557 outline.append_table_primitives(primitives, object_origin);
553558 outline.append_background_primitives(primitives, object_origin);
554 self.append_outline_objects(outline, object_origin, primitives);
559 self.append_outline_objects(outline, object_origin, None, primitives);
555560 for (index, paragraph) in outline.paragraphs.iter().enumerate() {
556561 outline.append_paragraph_primitives(
557562 index,
crates/snowbound/src/main.rs+87-2
......@@ -394,6 +394,10 @@ impl State {
394394 let (origin, size) = resize_image(origin, size, *handle, delta);
395395 return Some((*id, origin, size));
396396 }
397 // A picture in an outline's flow keeps its place.
398 if self.editor.image_in_outline(*id) {
399 return Some((*id, origin, size));
400 }
397401 let origin = [origin[0] + delta[0], origin[1] + delta[1]];
398402 let origin = if self.modifiers.alt_key() {
399403 origin
......@@ -892,7 +896,7 @@ impl State {
892896 if let Some(ObjectFocus::Image(id)) = self.object_focus
893897 && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete))
894898 {
895 self.editor.remove_image(id)?;
899 self.editor.remove_image(&mut self.engine, id)?;
896900 self.set_object_focus(None);
897901 return self.changed();
898902 }
......@@ -1592,7 +1596,9 @@ impl ApplicationHandler<UserEvent> for App {
15921596 } else {
15931597 let preview = state.preview();
15941598 if let Some((id, origin, size)) = state.image_preview() {
1595 state.editor.place_image(id, origin, size)?;
1599 state
1600 .editor
1601 .place_image(&mut state.engine, id, origin, size)?;
15961602 }
15971603 if let Some(Drag::Resize {
15981604 outline: Some(outline),
......@@ -1777,6 +1783,17 @@ fn page_hit_test(
17771783 return None;
17781784 }
17791785 }
1786 let inner = [x - outline.origin()[0], y - outline.origin()[1]];
1787 if let Some(picture) = outline.shaped().objects.iter().find(|object| {
1788 matches!(object.kind, canvas::outline::ObjectKind::Picture)
1789 && (object.rect[0]..=object.rect[2]).contains(&inner[0])
1790 && (object.rect[1]..=object.rect[3]).contains(&inner[1])
1791 }) {
1792 return Some(Hit::Image {
1793 id: picture.id,
1794 handle: [0, 0],
1795 });
1796 }
17801797 if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3])
17811798 || outline.layouts().any(|(_, paragraph)| {
17821799 paragraph.tags.iter().any(|tag| {
......@@ -1876,9 +1893,25 @@ fn page_primitives<'a>(
18761893 primitives,
18771894 )?;
18781895 if let Some((scene, _)) = scene {
1896 let moving = match preview {
1897 Some(PointerFeedback::Image(id, origin, size)) => {
1898 let [x0, y0, x1, y1] = image_rect(origin, size);
1899 Some((
1900 id,
1901 [
1902 x0 + offset[0],
1903 y0 + offset[1],
1904 x1 + offset[0],
1905 y1 + offset[1],
1906 ],
1907 ))
1908 }
1909 _ => None,
1910 };
18791911 scene.append_outline_objects(
18801912 outline.shaped(),
18811913 [origin[0] + offset[0], origin[1] + offset[1]],
1914 moving,
18821915 primitives,
18831916 );
18841917 }
......@@ -2748,6 +2781,58 @@ mod tests {
27482781 );
27492782 }
27502783
2784 #[test]
2785 fn a_picture_in_an_outline_takes_the_click_over_its_text() {
2786 use onestore::page::{Image, Page, PageObject, ParagraphContent};
2787 let mut engine = TextEngine::default();
2788 let mut source = TextOutline::new(
2789 &mut engine,
2790 TextDocument::new(vec![
2791 Paragraph::new("Before".into(), Default::default()),
2792 Paragraph::new("After".into(), Default::default()),
2793 ])
2794 .unwrap(),
2795 240.0,
2796 [36.0, 36.0],
2797 )
2798 .unwrap()
2799 .snapshot();
2800 let mut picture = source.paragraphs[0].clone();
2801 picture.id = onestore::page::text::new_id().unwrap();
2802 let id = onestore::page::text::new_id().unwrap();
2803 picture.content = ParagraphContent::Image(Image {
2804 size: Some([40.0, 30.0]),
2805 id,
2806 layout: Default::default(),
2807 bytes: None,
2808 alt: None,
2809 background: false,
2810 });
2811 source.paragraphs.insert(1, picture);
2812 let editor = CanvasEditor::from_page(
2813 Page {
2814 title: String::new(),
2815 identity: None,
2816 created: None,
2817 margin_origin: [36.0, 14.4],
2818 definitions: Default::default(),
2819 objects: vec![PageObject::Outline(source)],
2820 },
2821 &mut engine,
2822 )
2823 .unwrap();
2824 let (origin, size) = editor.image_placement(id).unwrap();
2825 let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0];
2826 assert_eq!(
2827 page_hit_test(&editor, None, center, 1.0),
2828 Some(Hit::Image { id, handle: [0, 0] })
2829 );
2830 assert!(matches!(
2831 page_hit_test(&editor, None, [origin[0] + 2.0, origin[1] - 4.0], 1.0),
2832 Some(Hit::Text { .. })
2833 ));
2834 }
2835
27512836 #[test]
27522837 fn picture_handles_resize_as_onenote_does() {
27532838 // Native drags of the 333 x 200.1 pt mockup on "av: casual night in the trees".
tools/canvas/README.md+1-1
......@@ -131,7 +131,7 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA
131131
132132## Unsupported page content
133133
134Pictures, files and handwriting that occupy a paragraph lay out in the outline's flow, at the paragraph indent with its spacing. A picture draws at its user-set layout size, else its intrinsic size. A file draws as OneNote does: the icon OneNote stored for it (24 pt when unsized) centered in a 54 pt column, with its name, less the extension, wrapped and centered below; a file without a stored icon keeps the empty slot. Handwriting strokes are relative to the paragraph's top-left, which the paragraph extends to reach. All three are part of the editable outline: text navigation passes over them, edits reflow around them, a selection spanning one deletes it, and Backspace or Delete does not join text across one.
134Pictures, files and handwriting that occupy a paragraph lay out in the outline's flow, at the paragraph indent with its spacing. A picture draws at its user-set layout size, else its intrinsic size. A file draws as OneNote does: the icon OneNote stored for it (24 pt when unsized) centered in a 54 pt column, with its name, less the extension, wrapped and centered below; a file without a stored icon keeps the empty slot. Handwriting strokes are relative to the paragraph's top-left, which the paragraph extends to reach. All three are part of the editable outline, and a picture there selects like a page picture, resizing and deleting in place through the outline's history (it keeps its place when dragged; Left and Right move the caret to the text beside it): text navigation passes over them, edits reflow around them, a selection spanning one deletes it, and Backspace or Delete does not join text across one.
135135
136136A paragraph of content the canvas cannot draw becomes a labelled placeholder box in the flow (at least 160 pt wide, its stored size when larger), and the rest of its outline stays editable. An outline the editor cannot hold, such as one with no text for the caret, draws as stored without editing. Unsupported top-level objects, titles, or outlines that cannot be drawn render as read-only placeholders in source order. Missing image payloads or dimensions produce an image-unavailable placeholder. The editor owns the imported source values for those objects; the importer’s `Unsupported` record contains identity, class and layout metadata, not an opaque copy of unparsed notebook bytes. Supported body outlines still enter the editor. If none exist, a provisional caret appears to the right of the placeholders; typing creates an annotation outline.
137137