diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 53b614afb15fd9a8753eb4d8012b2d3b0dc985d3..fa4ea84eae7bfd6e70764205795ee9b6172ceff3 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -1,6 +1,6 @@ use crate::{ date::PageDate, - document::{DocumentEdit, TextDocument, TextPosition, edited_nodes, leaves}, + document::{DocumentEdit, TextDocument, TextPosition, descendants, edited_nodes, leaves}, layout::{LayoutError, TextEngine}, outline::{ Arrange, OutlineLayout, ParagraphLayout, arrange, indentation, outline_layout, @@ -1278,11 +1278,60 @@ impl CanvasEditor { /// Origin and size of a picture the user can select; OneNote passes clicks through /// backgrounds. pub fn image_placement(&self, id: ExGuid) -> Option<([f32; 2], [f32; 2])> { - let layout = &self.image(id)?.layout; - Some(( - [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)], - [layout.max_width?, layout.max_height?], - )) + if let Some(image) = self.image(id) { + let layout = &image.layout; + return Some(( + [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)], + [layout.max_width?, layout.max_height?], + )); + } + let (outline, ..) = self.outline_picture(id)?; + let outline = self.outlines.iter().find(|item| item.id == outline)?; + let [x0, y0, x1, y1] = outline.shaped.objects.iter().find(|o| o.id == id)?.rect; + let origin = outline.origin(); + Some(([origin[0] + x0, origin[1] + y0], [x1 - x0, y1 - y0])) + } + + /// Whether the picture sits in an outline's flow, where only its size can change. + pub fn image_in_outline(&self, id: ExGuid) -> bool { + self.outline_picture(id).is_some() + } + + /// A picture held as a paragraph: its outline, the paragraph's container and index there, + /// and the paragraph. + fn outline_picture( + &self, + id: ExGuid, + ) -> Option<(ExGuid, Option, usize, &PageParagraph)> { + self.outlines.iter().find_map(|outline| { + descendants(outline.document.nodes(), None).find_map(|(container, index, node)| { + matches!(&node.content, onestore::page::ParagraphContent::Image(image) if image.id == id) + .then_some((outline.id, container, index, node)) + }) + }) + } + + /// Replaces a picture paragraph through the outline's text history. + fn edit_outline_picture( + &mut self, + engine: &mut TextEngine, + id: ExGuid, + replacement: Option, + ) -> Result<(), EditorError> { + let (outline, container, index, _) = + self.outline_picture(id).ok_or(EditError::InvalidRange)?; + self.focus_outline(outline)?; + let selection = self.selection(); + self.commit( + engine, + DocumentEdit { + columns: BTreeMap::new(), + container, + range: index..index + 1, + replacement: replacement.into_iter().collect(), + }, + selection, + ) } fn image(&self, id: ExGuid) -> Option<&onestore::page::Image> { @@ -1299,17 +1348,31 @@ impl CanvasEditor { }) } - /// Moves a picture, and resizes it when `size` differs from the stored size. + /// Moves a picture, and resizes it when `size` differs from the stored size. A picture in + /// an outline's flow keeps its place. pub fn place_image( &mut self, + engine: &mut TextEngine, id: ExGuid, origin: [f32; 2], size: [f32; 2], - ) -> Result<(), EditError> { + ) -> Result<(), EditorError> { if !origin.iter().chain(&size).all(|value| value.is_finite()) || size.iter().any(|value| *value <= 0.0) { - return Err(EditError::InvalidRange); + return Err(EditError::InvalidRange.into()); + } + if let Some((.., node)) = self.outline_picture(id) { + let mut node = node.clone(); + let onestore::page::ParagraphContent::Image(image) = &mut node.content else { + unreachable!() + }; + if crate::outline::image_size(image) == Some(size) { + return Ok(()); + } + [image.layout.max_width, image.layout.max_height] = size.map(Some); + image.layout.width_set_by_user = Some(true); + return self.edit_outline_picture(engine, id, Some(node)); } let image = self.image(id).ok_or(EditError::InvalidRange)?; let mut layout = image.layout.clone(); @@ -1340,6 +1403,44 @@ impl CanvasEditor { id: ExGuid, forward: bool, ) -> Result { + if let Some((outline, ..)) = self.outline_picture(id) { + // Within an outline, the caret goes to the text beside the picture. + let source = self + .outlines + .iter() + .find(|item| item.id == outline) + .unwrap(); + let mut leaf = 0; + let mut before = None; + let mut after = None; + let mut passed = false; + for (_, _, node) in descendants(source.document.nodes(), None) { + match &node.content { + onestore::page::ParagraphContent::Image(image) if image.id == id => { + passed = true + } + onestore::page::ParagraphContent::Text(text) => { + let end = text.text.utf16_offset(text.text.text().len())?; + if passed { + after = after.or(Some((leaf, 0))); + } else { + before = Some((leaf, end)); + } + leaf += 1; + } + _ => {} + } + } + let Some((paragraph, offset)) = (if forward { after } else { before }) else { + return Ok(false); + }; + self.focus_outline(outline)?; + self.select(Selection { + positions: [TextPosition { paragraph, offset }; 2], + affinities: [Affinity::Downstream; 2], + })?; + return Ok(true); + } let index = self .objects .iter() @@ -1367,7 +1468,10 @@ impl CanvasEditor { Ok(true) } - pub fn remove_image(&mut self, id: ExGuid) -> Result<(), EditError> { + pub fn remove_image(&mut self, engine: &mut TextEngine, id: ExGuid) -> Result<(), EditorError> { + if self.outline_picture(id).is_some() { + return self.edit_outline_picture(engine, id, None); + } let index = self .objects .iter() @@ -4538,22 +4642,22 @@ mod tests { Some(([468.75, 86.4], [333.0, 200.1])) ); assert!(editor.image_placement(background_id).is_none()); - assert_eq!( - editor.place_image(background_id, [0.0; 2], [10.0; 2]), - Err(EditError::InvalidRange) - ); - assert_eq!( - editor.place_image(id, [0.0; 2], [0.0, 10.0]), - Err(EditError::InvalidRange) - ); + assert!(matches!( + editor.place_image(&mut engine, background_id, [0.0; 2], [10.0; 2]), + Err(EditorError::Edit(EditError::InvalidRange)) + )); + assert!(matches!( + editor.place_image(&mut engine, id, [0.0; 2], [0.0, 10.0]), + Err(EditorError::Edit(EditError::InvalidRange)) + )); editor - .place_image(id, [513.75, 104.4], [333.0, 200.1]) + .place_image(&mut engine, id, [513.75, 104.4], [333.0, 200.1]) .unwrap(); let moved = stored(&editor); assert_eq!([moved.x, moved.y], [Some(513.75), Some(104.4)]); assert_eq!(moved.width_set_by_user, None); editor - .place_image(id, [513.75, 104.4], [281.8, 169.4]) + .place_image(&mut engine, id, [513.75, 104.4], [281.8, 169.4]) .unwrap(); let resized = stored(&editor); assert_eq!( @@ -4579,11 +4683,11 @@ mod tests { .collect::>() }; let before = ids(&editor); - assert_eq!( - editor.remove_image(background_id), - Err(EditError::InvalidRange) - ); - editor.remove_image(id).unwrap(); + assert!(matches!( + editor.remove_image(&mut engine, background_id), + Err(EditorError::Edit(EditError::InvalidRange)) + )); + editor.remove_image(&mut engine, id).unwrap(); assert!(editor.image_placement(id).is_none()); assert_eq!(ids(&editor), [background_id]); assert!(editor.undo(&mut engine).unwrap()); @@ -4732,6 +4836,53 @@ mod tests { assert!(editor.undo(&mut engine).unwrap()); assert_eq!(editor.active_outline().shaped().paragraphs.len(), 2); assert_eq!(editor.active_outline().shaped().objects.len(), 1); + + // Selected, it resizes and deletes in place through the outline's history. + assert!(editor.image_in_outline(image.id)); + let (origin, size) = editor.image_placement(image.id).unwrap(); + assert_eq!(size, [40.0, 30.0]); + editor + .place_image(&mut engine, image.id, [0.0; 2], [80.0, 60.0]) + .unwrap(); + assert_eq!( + editor.image_placement(image.id), + Some((origin, [80.0, 60.0])) + ); + let shaped = editor.active_outline().shaped(); + assert!(shaped.paragraphs[1].origin[1] >= shaped.objects[0].bottom); + assert!(editor.step_from_image(&mut engine, image.id, true).unwrap()); + assert_eq!( + editor.selection().positions[0], + TextPosition { + paragraph: 1, + offset: 0 + } + ); + assert!( + editor + .step_from_image(&mut engine, image.id, false) + .unwrap() + ); + assert_eq!( + editor.selection().positions[0], + TextPosition { + paragraph: 0, + offset: 6 + } + ); + editor.remove_image(&mut engine, image.id).unwrap(); + assert!(editor.image_placement(image.id).is_none()); + assert!(editor.active_outline().shaped().objects.is_empty()); + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!( + editor.image_placement(image.id), + Some((origin, [80.0, 60.0])) + ); + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!( + editor.image_placement(image.id), + Some((origin, [40.0, 30.0])) + ); } #[test] diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 9f70bb05709b010ccda1adc7b74182ed21493c5c..d8827b62d7b085760065c7c3e55639425d3c54f4 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -213,20 +213,25 @@ impl PageScene { } /// Pictures, files and handwriting inside an outline whose origin is `origin`. + /// `moving` draws one picture at a previewed rectangle, in the same coordinates as `origin`. pub fn append_outline_objects<'a>( &'a self, outline: &'a crate::outline::OutlineLayout, origin: [f32; 2], + moving: Option<(onestore::ExGuid, [f32; 4])>, primitives: &mut Vec>, ) { for object in &outline.objects { let [x0, y0, x1, y1] = object.rect; - let rect = [ - x0 + origin[0], - y0 + origin[1], - x1 + origin[0], - y1 + origin[1], - ]; + let rect = match moving { + Some((id, rect)) if id == object.id => rect, + _ => [ + x0 + origin[0], + y0 + origin[1], + x1 + origin[0], + y1 + origin[1], + ], + }; if let Some(image) = self.images.get(&object.id) { primitives.push(Primitive::Image { image, rect }); } @@ -551,7 +556,7 @@ impl PageScene { }; outline.append_table_primitives(primitives, object_origin); outline.append_background_primitives(primitives, object_origin); - self.append_outline_objects(outline, object_origin, primitives); + self.append_outline_objects(outline, object_origin, None, primitives); for (index, paragraph) in outline.paragraphs.iter().enumerate() { outline.append_paragraph_primitives( index, diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 7e84c7ad30a0f153e5e9e475bd303e378e92aefc..9bdcefce74dfe849c65ae0d01bb5f947172bbedc 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -394,6 +394,10 @@ impl State { let (origin, size) = resize_image(origin, size, *handle, delta); return Some((*id, origin, size)); } + // A picture in an outline's flow keeps its place. + if self.editor.image_in_outline(*id) { + return Some((*id, origin, size)); + } let origin = [origin[0] + delta[0], origin[1] + delta[1]]; let origin = if self.modifiers.alt_key() { origin @@ -892,7 +896,7 @@ impl State { if let Some(ObjectFocus::Image(id)) = self.object_focus && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete)) { - self.editor.remove_image(id)?; + self.editor.remove_image(&mut self.engine, id)?; self.set_object_focus(None); return self.changed(); } @@ -1592,7 +1596,9 @@ impl ApplicationHandler for App { } else { let preview = state.preview(); if let Some((id, origin, size)) = state.image_preview() { - state.editor.place_image(id, origin, size)?; + state + .editor + .place_image(&mut state.engine, id, origin, size)?; } if let Some(Drag::Resize { outline: Some(outline), @@ -1777,6 +1783,17 @@ fn page_hit_test( return None; } } + let inner = [x - outline.origin()[0], y - outline.origin()[1]]; + if let Some(picture) = outline.shaped().objects.iter().find(|object| { + matches!(object.kind, canvas::outline::ObjectKind::Picture) + && (object.rect[0]..=object.rect[2]).contains(&inner[0]) + && (object.rect[1]..=object.rect[3]).contains(&inner[1]) + }) { + return Some(Hit::Image { + id: picture.id, + handle: [0, 0], + }); + } if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3]) || outline.layouts().any(|(_, paragraph)| { paragraph.tags.iter().any(|tag| { @@ -1876,9 +1893,25 @@ fn page_primitives<'a>( primitives, )?; if let Some((scene, _)) = scene { + let moving = match preview { + Some(PointerFeedback::Image(id, origin, size)) => { + let [x0, y0, x1, y1] = image_rect(origin, size); + Some(( + id, + [ + x0 + offset[0], + y0 + offset[1], + x1 + offset[0], + y1 + offset[1], + ], + )) + } + _ => None, + }; scene.append_outline_objects( outline.shaped(), [origin[0] + offset[0], origin[1] + offset[1]], + moving, primitives, ); } @@ -2748,6 +2781,58 @@ mod tests { ); } + #[test] + fn a_picture_in_an_outline_takes_the_click_over_its_text() { + use onestore::page::{Image, Page, PageObject, ParagraphContent}; + let mut engine = TextEngine::default(); + let mut source = TextOutline::new( + &mut engine, + TextDocument::new(vec![ + Paragraph::new("Before".into(), Default::default()), + Paragraph::new("After".into(), Default::default()), + ]) + .unwrap(), + 240.0, + [36.0, 36.0], + ) + .unwrap() + .snapshot(); + let mut picture = source.paragraphs[0].clone(); + picture.id = onestore::page::text::new_id().unwrap(); + let id = onestore::page::text::new_id().unwrap(); + picture.content = ParagraphContent::Image(Image { + size: Some([40.0, 30.0]), + id, + layout: Default::default(), + bytes: None, + alt: None, + background: false, + }); + source.paragraphs.insert(1, picture); + let editor = CanvasEditor::from_page( + Page { + title: String::new(), + identity: None, + created: None, + margin_origin: [36.0, 14.4], + definitions: Default::default(), + objects: vec![PageObject::Outline(source)], + }, + &mut engine, + ) + .unwrap(); + let (origin, size) = editor.image_placement(id).unwrap(); + let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0]; + assert_eq!( + page_hit_test(&editor, None, center, 1.0), + Some(Hit::Image { id, handle: [0, 0] }) + ); + assert!(matches!( + page_hit_test(&editor, None, [origin[0] + 2.0, origin[1] - 4.0], 1.0), + Some(Hit::Text { .. }) + )); + } + #[test] fn picture_handles_resize_as_onenote_does() { // Native drags of the 333 x 200.1 pt mockup on "av: casual night in the trees". diff --git a/tools/canvas/README.md b/tools/canvas/README.md index c5ed6bb9c5ad24133149adc75b682f47949c6fa5..d6f95eaaf07a211d396fb54bdbebd3344cfcad85 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -131,7 +131,7 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA ## Unsupported page content -Pictures, 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. +Pictures, 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. A 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.