| author | |
| committer | |
| log | b822df2242bfd846f6475e49af173e4736187b4b |
| tree | 8d8e0b08b4b157bbec9db34f9c153d091873b218 |
| parent | 5b135a22151997c7b480c1e65db0c2d85ad41712 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
One unknown paragraph made its whole outline a read-only placeholder. It now lays out as a
labelled placeholder box in the flow and the rest of the outline stays editable. An outline
the editor cannot hold, such as a picture with no text beside it, draws as stored rather than
as a placeholder. Object layouts carry their kind (picture, file, ink or placeholder) instead
of optional fields.
Assisted-by: claude-opus-5.56 files changed, 227 insertions(+), 109 deletions(-)
crates/canvas/src/document.rs+13-13| ... | @@ -170,7 +170,11 @@ pub(crate) fn validate_nodes( | ... | @@ -170,7 +170,11 @@ pub(crate) fn validate_nodes( |
| 170 | return Err(EditError::InvalidStructure); | 170 | return Err(EditError::InvalidStructure); |
| 171 | } | 171 | } |
| 172 | } | 172 | } |
| 173 | ParagraphContent::Unsupported(_) => return Err(EditError::UnsupportedContent), | 173 | ParagraphContent::Unsupported(unsupported) => { |
| 174 | if !ids.insert(unsupported.id) { | ||
| 175 | return Err(EditError::InvalidStructure); | ||
| 176 | } | ||
| 177 | } | ||
| 174 | ParagraphContent::Table(table) => { | 178 | ParagraphContent::Table(table) => { |
| 175 | if !ids.insert(table.id) | 179 | if !ids.insert(table.id) |
| 176 | || table.rows.is_empty() | 180 | || table.rows.is_empty() |
| ... | @@ -942,7 +946,7 @@ mod tests { | ... | @@ -942,7 +946,7 @@ mod tests { |
| 942 | } | 946 | } |
| 943 | 947 | ||
| 944 | #[test] | 948 | #[test] |
| 945 | fn replacement_cannot_alias_existing_objects_or_publish_unsupported_nodes() { | 949 | fn replacement_cannot_alias_existing_objects() { |
| 946 | let mut document = | 950 | let mut document = |
| 947 | TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(); | 951 | TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(); |
| 948 | let original = document.clone(); | 952 | let original = document.clone(); |
| ... | @@ -960,18 +964,14 @@ mod tests { | ... | @@ -960,18 +964,14 @@ mod tests { |
| 960 | jcid: 0x60012, | 964 | jcid: 0x60012, |
| 961 | layout: Default::default(), | 965 | layout: Default::default(), |
| 962 | }); | 966 | }); |
| 967 | // Content the canvas cannot draw is kept as a placeholder paragraph, once. | ||
| 968 | assert!(TextDocument::from_nodes(unsupported.clone()).is_ok()); | ||
| 969 | let mut twice = unsupported.clone(); | ||
| 970 | twice[0].id = new_id().unwrap(); | ||
| 971 | twice.extend(unsupported); | ||
| 963 | assert_eq!( | 972 | assert_eq!( |
| 964 | TextDocument::from_nodes(unsupported.clone()), | 973 | TextDocument::from_nodes(twice), |
| 965 | Err(EditError::UnsupportedContent) | 974 | Err(EditError::InvalidStructure) |
| 966 | ); | ||
| 967 | assert_eq!( | ||
| 968 | document.apply(DocumentEdit { | ||
| 969 | columns: BTreeMap::new(), | ||
| 970 | container: None, | ||
| 971 | range: 0..1, | ||
| 972 | replacement: unsupported | ||
| 973 | }), | ||
| 974 | Err(EditError::UnsupportedContent) | ||
| 975 | ); | 975 | ); |
| 976 | assert_eq!(document, original); | 976 | assert_eq!(document, original); |
| 977 | } | 977 | } |
crates/canvas/src/editor.rs+85-20| ... | @@ -255,6 +255,10 @@ impl TextOutline { | ... | @@ -255,6 +255,10 @@ impl TextOutline { |
| 255 | outline.layout(engine, definitions)? | 255 | outline.layout(engine, definitions)? |
| 256 | }; | 256 | }; |
| 257 | let document = TextDocument::from_nodes(outline.paragraphs.clone())?; | 257 | let document = TextDocument::from_nodes(outline.paragraphs.clone())?; |
| 258 | // The caret needs a paragraph of text to stand in. | ||
| 259 | if document.text_nodes().next().is_none() { | ||
| 260 | return Err(EditError::UnsupportedContent.into()); | ||
| 261 | } | ||
| 258 | Ok(Self { | 262 | Ok(Self { |
| 259 | id: outline.id, | 263 | id: outline.id, |
| 260 | title: outline.title, | 264 | title: outline.title, |
| ... | @@ -458,10 +462,8 @@ impl TextOutline { | ... | @@ -458,10 +462,8 @@ impl TextOutline { |
| 458 | } | 462 | } |
| 459 | onestore::page::ParagraphContent::Image(_) | 463 | onestore::page::ParagraphContent::Image(_) |
| 460 | | onestore::page::ParagraphContent::Attachment(_) | 464 | | onestore::page::ParagraphContent::Attachment(_) |
| 461 | | onestore::page::ParagraphContent::Ink(_) => {} | 465 | | onestore::page::ParagraphContent::Ink(_) |
| 462 | onestore::page::ParagraphContent::Unsupported(_) => { | 466 | | onestore::page::ParagraphContent::Unsupported(_) => {} |
| 463 | return Err(EditError::UnsupportedContent); | ||
| 464 | } | ||
| 465 | } | 467 | } |
| 466 | } | 468 | } |
| 467 | Ok(result) | 469 | Ok(result) |
| ... | @@ -508,13 +510,11 @@ impl TextOutline { | ... | @@ -508,13 +510,11 @@ impl TextOutline { |
| 508 | .rect[1] | 510 | .rect[1] |
| 509 | - 1.86 | 511 | - 1.86 |
| 510 | } | 512 | } |
| 511 | // Pictures, files and ink hold no caret; text around them takes the hit. | 513 | // Objects hold no caret; text around them takes the hit. |
| 512 | onestore::page::ParagraphContent::Image(_) | 514 | onestore::page::ParagraphContent::Image(_) |
| 513 | | onestore::page::ParagraphContent::Attachment(_) | 515 | | onestore::page::ParagraphContent::Attachment(_) |
| 514 | | onestore::page::ParagraphContent::Ink(_) => continue, | 516 | | onestore::page::ParagraphContent::Ink(_) |
| 515 | onestore::page::ParagraphContent::Unsupported(_) => { | 517 | | onestore::page::ParagraphContent::Unsupported(_) => continue, |
| 516 | return Err(EditError::UnsupportedContent); | ||
| 517 | } | ||
| 518 | }; | 518 | }; |
| 519 | if target.is_none() || top <= y { | 519 | if target.is_none() || top <= y { |
| 520 | target = Some(node); | 520 | target = Some(node); |
| ... | @@ -1829,16 +1829,16 @@ impl CanvasEditor { | ... | @@ -1829,16 +1829,16 @@ impl CanvasEditor { |
| 1829 | ) | 1829 | ) |
| 1830 | | onestore::page::ParagraphContent::Ink(onestore::page::Ink { | 1830 | | onestore::page::ParagraphContent::Ink(onestore::page::Ink { |
| 1831 | id, .. | 1831 | id, .. |
| 1832 | }) => outline | 1832 | }) |
| 1833 | | onestore::page::ParagraphContent::Unsupported( | ||
| 1834 | onestore::page::Unsupported { id, .. }, | ||
| 1835 | ) => outline | ||
| 1833 | .shaped | 1836 | .shaped |
| 1834 | .objects | 1837 | .objects |
| 1835 | .iter() | 1838 | .iter() |
| 1836 | .find(|object| object.id == *id) | 1839 | .find(|object| object.id == *id) |
| 1837 | .map(|object| f64::from(object.bottom)) | 1840 | .map(|object| f64::from(object.bottom)) |
| 1838 | .ok_or(EditError::InvalidStructure)?, | 1841 | .ok_or(EditError::InvalidStructure)?, |
| 1839 | onestore::page::ParagraphContent::Unsupported(_) => { | ||
| 1840 | return Err(EditError::UnsupportedContent.into()); | ||
| 1841 | } | ||
| 1842 | } + f64::from(outline.origin()[1]); | 1842 | } + f64::from(outline.origin()[1]); |
| 1843 | let cell = (bottom - 14.4) / 18.0; | 1843 | let cell = (bottom - 14.4) / 18.0; |
| 1844 | // Stored coordinates can straddle a grid boundary by one f32 ULP. | 1844 | // Stored coordinates can straddle a grid boundary by one f32 ULP. |
| ... | @@ -2848,13 +2848,7 @@ impl CanvasEditor { | ... | @@ -2848,13 +2848,7 @@ impl CanvasEditor { |
| 2848 | .shaped | 2848 | .shaped |
| 2849 | .paragraphs | 2849 | .paragraphs |
| 2850 | .iter() | 2850 | .iter() |
| 2851 | .chain( | 2851 | .chain(outline.shaped.objects.iter().filter_map(|o| o.label())) |
| 2852 | outline | ||
| 2853 | .shaped | ||
| 2854 | .objects | ||
| 2855 | .iter() | ||
| 2856 | .filter_map(|o| o.label.as_ref()), | ||
| 2857 | ) | ||
| 2858 | .map(|paragraph| (paragraph.id, paragraph)) | 2852 | .map(|paragraph| (paragraph.id, paragraph)) |
| 2859 | .collect::<BTreeMap<_, _>>(); | 2853 | .collect::<BTreeMap<_, _>>(); |
| 2860 | let shaped = OutlineLayout::flow( | 2854 | let shaped = OutlineLayout::flow( |
| ... | @@ -4740,6 +4734,77 @@ mod tests { | ... | @@ -4740,6 +4734,77 @@ mod tests { |
| 4740 | assert_eq!(editor.active_outline().shaped().objects.len(), 1); | 4734 | assert_eq!(editor.active_outline().shaped().objects.len(), 1); |
| 4741 | } | 4735 | } |
| 4742 | 4736 | ||
| 4737 | #[test] | ||
| 4738 | fn unknown_paragraphs_and_text_free_outlines_stay_on_the_page() { | ||
| 4739 | use onestore::page::{Image, Page, PageObject, ParagraphContent, Unsupported}; | ||
| 4740 | let mut engine = TextEngine::default(); | ||
| 4741 | let outline = |engine: &mut TextEngine| { | ||
| 4742 | TextOutline::new( | ||
| 4743 | engine, | ||
| 4744 | TextDocument::new(vec![ | ||
| 4745 | Paragraph::new("Before".into(), Default::default()), | ||
| 4746 | Paragraph::new("After".into(), Default::default()), | ||
| 4747 | ]) | ||
| 4748 | .unwrap(), | ||
| 4749 | 240.0, | ||
| 4750 | [36.0, 36.0], | ||
| 4751 | ) | ||
| 4752 | .unwrap() | ||
| 4753 | .snapshot() | ||
| 4754 | }; | ||
| 4755 | let mut mixed = outline(&mut engine); | ||
| 4756 | let mut unknown = mixed.paragraphs[0].clone(); | ||
| 4757 | unknown.id = onestore::page::text::new_id().unwrap(); | ||
| 4758 | unknown.content = ParagraphContent::Unsupported(Unsupported { | ||
| 4759 | id: onestore::page::text::new_id().unwrap(), | ||
| 4760 | jcid: 0x60012, | ||
| 4761 | layout: Default::default(), | ||
| 4762 | }); | ||
| 4763 | mixed.paragraphs.insert(1, unknown); | ||
| 4764 | let mut picture_only = outline(&mut engine); | ||
| 4765 | picture_only.id = onestore::page::text::new_id().unwrap(); | ||
| 4766 | picture_only.paragraphs.truncate(1); | ||
| 4767 | picture_only.paragraphs[0].content = ParagraphContent::Image(Image { | ||
| 4768 | size: Some([40.0, 30.0]), | ||
| 4769 | id: onestore::page::text::new_id().unwrap(), | ||
| 4770 | layout: Default::default(), | ||
| 4771 | bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())), | ||
| 4772 | alt: None, | ||
| 4773 | background: false, | ||
| 4774 | }); | ||
| 4775 | let (mixed_id, picture_id) = (mixed.id, picture_only.id); | ||
| 4776 | let editor = CanvasEditor::from_page( | ||
| 4777 | Page { | ||
| 4778 | title: String::new(), | ||
| 4779 | identity: None, | ||
| 4780 | created: None, | ||
| 4781 | margin_origin: [36.0, 14.4], | ||
| 4782 | definitions: BTreeMap::new(), | ||
| 4783 | objects: vec![ | ||
| 4784 | PageObject::Outline(mixed), | ||
| 4785 | PageObject::Outline(picture_only), | ||
| 4786 | ], | ||
| 4787 | }, | ||
| 4788 | &mut engine, | ||
| 4789 | ) | ||
| 4790 | .unwrap(); | ||
| 4791 | let mixed = editor.outlines().iter().find(|o| o.id == mixed_id).unwrap(); | ||
| 4792 | let [placeholder] = &mixed.shaped().objects[..] else { | ||
| 4793 | panic!() | ||
| 4794 | }; | ||
| 4795 | assert!(matches!( | ||
| 4796 | placeholder.kind, | ||
| 4797 | crate::outline::ObjectKind::Unsupported(_) | ||
| 4798 | )); | ||
| 4799 | assert_eq!(placeholder.rect[2] - placeholder.rect[0], 160.0); | ||
| 4800 | assert!(mixed.shaped().paragraphs[1].origin[1] >= placeholder.bottom); | ||
| 4801 | assert!(editor.objects.iter().any(|object| matches!( | ||
| 4802 | object, | ||
| 4803 | page::Content::Outline { source, .. } if source.id == picture_id | ||
| 4804 | ))); | ||
| 4805 | assert_eq!(editor.page().unwrap().objects.len(), 2); | ||
| 4806 | } | ||
| 4807 | |||
| 4743 | #[test] | 4808 | #[test] |
| 4744 | fn title_flow_moves_page_objects_atomically_and_cancels_composition() { | 4809 | fn title_flow_moves_page_objects_atomically_and_cancels_composition() { |
| 4745 | use onestore::page::{Image, Page, PageObject, Title}; | 4810 | use onestore::page::{Image, Page, PageObject, Title}; |
crates/canvas/src/editor/page.rs+27-26| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | use super::{EditorError, TextOutline}; | 1 | use super::{EditError, EditorError, TextOutline}; |
| 2 | use crate::{ | 2 | use crate::{ |
| 3 | date::PageDate, | 3 | date::PageDate, |
| 4 | layout::{LayoutError, TextEngine, TextLayout}, | 4 | layout::{LayoutError, TextEngine, TextLayout}, |
| ... | @@ -153,39 +153,40 @@ pub(crate) fn build( | ... | @@ -153,39 +153,40 @@ pub(crate) fn build( |
| 153 | if origin.iter().any(|v| !v.is_finite()) { | 153 | if origin.iter().any(|v| !v.is_finite()) { |
| 154 | return Err(EditorError::InvalidGeometry); | 154 | return Err(EditorError::InvalidGeometry); |
| 155 | } | 155 | } |
| 156 | let unsupported = |error: &EditorError| { | ||
| 157 | matches!( | ||
| 158 | error, | ||
| 159 | EditorError::Layout(LayoutError::UnsupportedContent) | ||
| 160 | | EditorError::Edit(EditError::UnsupportedContent) | ||
| 161 | ) | ||
| 162 | }; | ||
| 156 | if editable { | 163 | if editable { |
| 157 | match TextOutline::from_outline(engine, outline, &page.definitions) { | 164 | match TextOutline::from_outline(engine, outline, &page.definitions) { |
| 158 | Ok(outline) => { | 165 | Ok(outline) => { |
| 159 | objects.push(Content::Editable(outline.id)); | 166 | objects.push(Content::Editable(outline.id)); |
| 160 | outlines.push(outline); | 167 | outlines.push(outline); |
| 168 | continue; | ||
| 161 | } | 169 | } |
| 162 | Err(EditorError::Layout(LayoutError::UnsupportedContent)) => { | 170 | Err(error) if !unsupported(&error) => return Err(error), |
| 163 | objects.push(Content::ReadOnly(ReadOnlyObject::new( | 171 | // Drawn as stored when the editor cannot hold it. |
| 164 | object, | 172 | Err(_) => {} |
| 165 | page.margin_origin, | ||
| 166 | "Unsupported content\nRead-only", | ||
| 167 | engine, | ||
| 168 | )?)) | ||
| 169 | } | ||
| 170 | Err(error) => return Err(error), | ||
| 171 | } | 173 | } |
| 172 | } else { | 174 | } |
| 173 | match outline.layout(engine, &page.definitions) { | 175 | match outline.layout(engine, &page.definitions) { |
| 174 | Ok(layout) => objects.push(Content::Outline { | 176 | Ok(layout) => objects.push(Content::Outline { |
| 175 | source: outline.clone(), | 177 | source: outline.clone(), |
| 176 | layout, | 178 | layout, |
| 177 | below_title: None, | 179 | below_title: None, |
| 178 | }), | 180 | }), |
| 179 | Err(LayoutError::UnsupportedContent) => { | 181 | Err(LayoutError::UnsupportedContent) => { |
| 180 | objects.push(Content::ReadOnly(ReadOnlyObject::new( | 182 | objects.push(Content::ReadOnly(ReadOnlyObject::new( |
| 181 | object, | 183 | object, |
| 182 | page.margin_origin, | 184 | page.margin_origin, |
| 183 | "Unsupported content\nRead-only", | 185 | "Unsupported content\nRead-only", |
| 184 | engine, | 186 | engine, |
| 185 | )?)) | 187 | )?)) |
| 186 | } | ||
| 187 | Err(error) => return Err(error.into()), | ||
| 188 | } | 188 | } |
| 189 | Err(error) => return Err(error.into()), | ||
| 189 | } | 190 | } |
| 190 | } | 191 | } |
| 191 | PageObject::Title(title) => { | 192 | PageObject::Title(title) => { |
crates/canvas/src/gpu/page.rs+24-17| ... | @@ -220,26 +220,33 @@ impl PageScene { | ... | @@ -220,26 +220,33 @@ impl PageScene { |
| 220 | primitives: &mut Vec<Primitive<'a>>, | 220 | primitives: &mut Vec<Primitive<'a>>, |
| 221 | ) { | 221 | ) { |
| 222 | for object in &outline.objects { | 222 | for object in &outline.objects { |
| 223 | 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 | ]; | ||
| 223 | if let Some(image) = self.images.get(&object.id) { | 230 | if let Some(image) = self.images.get(&object.id) { |
| 224 | let [x0, y0, x1, y1] = object.rect; | 231 | primitives.push(Primitive::Image { image, rect }); |
| 225 | primitives.push(Primitive::Image { | ||
| 226 | image, | ||
| 227 | rect: [ | ||
| 228 | x0 + origin[0], | ||
| 229 | y0 + origin[1], | ||
| 230 | x1 + origin[0], | ||
| 231 | y1 + origin[1], | ||
| 232 | ], | ||
| 233 | }); | ||
| 234 | } | 232 | } |
| 235 | if let Some(ink) = &object.ink { | 233 | match &object.kind { |
| 236 | append_ink( | 234 | crate::outline::ObjectKind::Ink(ink) => { |
| 237 | ink, | 235 | append_ink(ink, [rect[0], rect[1]], primitives) |
| 238 | [origin[0] + object.rect[0], origin[1] + object.rect[1]], | 236 | } |
| 239 | primitives, | 237 | crate::outline::ObjectKind::Unsupported(_) => { |
| 240 | ); | 238 | primitives.push(Primitive::Rect { |
| 239 | rect, | ||
| 240 | color: colorref(0x00e4ddd6), | ||
| 241 | }); | ||
| 242 | primitives.push(Primitive::Rect { | ||
| 243 | rect: [rect[0] + 1.0, rect[1] + 1.0, rect[2] - 1.0, rect[3] - 1.0], | ||
| 244 | color: colorref(0x00faf7f3), | ||
| 245 | }); | ||
| 246 | } | ||
| 247 | crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {} | ||
| 241 | } | 248 | } |
| 242 | if let Some(label) = &object.label { | 249 | if let Some(label) = object.label() { |
| 243 | primitives.push(Primitive::Text { | 250 | primitives.push(Primitive::Text { |
| 244 | layout: &label.text, | 251 | layout: &label.text, |
| 245 | origin: [origin[0] + label.origin[0], origin[1] + label.origin[1]], | 252 | origin: [origin[0] + label.origin[0], origin[1] + label.origin[1]], |
crates/canvas/src/outline.rs+77-32| ... | @@ -23,18 +23,35 @@ pub struct OutlineLayout { | ... | @@ -23,18 +23,35 @@ pub struct OutlineLayout { |
| 23 | 23 | ||
| 24 | #[derive(Clone)] | 24 | #[derive(Clone)] |
| 25 | pub struct ObjectLayout { | 25 | pub struct ObjectLayout { |
| 26 | /// The picture's, file's or drawing's identity, which keys a decoded image. | 26 | /// The object's identity, which keys a picture's or file icon's decoded image. |
| 27 | pub id: ExGuid, | 27 | pub id: ExGuid, |
| 28 | /// Where the picture or file icon draws, outline-local. | 28 | /// Where the picture, file icon, drawing or placeholder draws, outline-local. |
| 29 | pub rect: [f32; 4], | 29 | pub rect: [f32; 4], |
| 30 | /// A file's name, centered under its icon. | 30 | pub kind: ObjectKind, |
| 31 | pub label: Option<ParagraphLayout>, | ||
| 32 | /// Handwriting, whose strokes are relative to the rect's top-left. | ||
| 33 | pub ink: Option<onestore::page::Ink>, | ||
| 34 | /// Outline-local bottom of the whole object, label included. | 31 | /// Outline-local bottom of the whole object, label included. |
| 35 | pub bottom: f32, | 32 | pub bottom: f32, |
| 36 | } | 33 | } |
| 37 | 34 | ||
| 35 | #[derive(Clone)] | ||
| 36 | pub enum ObjectKind { | ||
| 37 | Picture, | ||
| 38 | /// A file's icon with its name centered below. | ||
| 39 | File(ParagraphLayout), | ||
| 40 | /// Handwriting, whose strokes are relative to the rect's top-left. | ||
| 41 | Ink(onestore::page::Ink), | ||
| 42 | /// Content the canvas cannot draw, marked by a labelled box. | ||
| 43 | Unsupported(ParagraphLayout), | ||
| 44 | } | ||
| 45 | |||
| 46 | impl ObjectLayout { | ||
| 47 | pub fn label(&self) -> Option<&ParagraphLayout> { | ||
| 48 | match &self.kind { | ||
| 49 | ObjectKind::File(label) | ObjectKind::Unsupported(label) => Some(label), | ||
| 50 | ObjectKind::Picture | ObjectKind::Ink(_) => None, | ||
| 51 | } | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 38 | /// OneNote centers a file's icon and name in a column this wide. | 55 | /// OneNote centers a file's icon and name in a column this wide. |
| 39 | const ATTACHMENT_WIDTH: f32 = 54.0; | 56 | const ATTACHMENT_WIDTH: f32 = 54.0; |
| 40 | 57 | ||
| ... | @@ -114,28 +131,19 @@ impl ParagraphTag { | ... | @@ -114,28 +131,19 @@ impl ParagraphTag { |
| 114 | pub const SIZE: f32 = 12.0; | 131 | pub const SIZE: f32 = 12.0; |
| 115 | } | 132 | } |
| 116 | 133 | ||
| 117 | /// The centered caption OneNote draws under a file's icon: its name without the extension. | 134 | /// A one-run paragraph standing in for an object's caption, so it lays out through the same |
| 118 | fn attachment_label(node: &PageParagraph, file: &onestore::page::Attachment) -> PageParagraph { | 135 | /// shaping (and caching) as the outline's text. |
| 119 | let name = std::path::Path::new(&file.filename) | 136 | fn caption(id: ExGuid, text_id: ExGuid, text: &str, format: Format) -> PageParagraph { |
| 120 | .file_stem() | ||
| 121 | .and_then(|stem| stem.to_str()) | ||
| 122 | .unwrap_or(&file.filename); | ||
| 123 | PageParagraph { | 137 | PageParagraph { |
| 124 | id: node.id, | 138 | id, |
| 125 | parent: None, | 139 | parent: None, |
| 126 | level: 1, | 140 | level: 1, |
| 127 | style: None, | 141 | style: None, |
| 128 | format: Format::default(), | 142 | format: Format::default(), |
| 129 | content: ParagraphContent::Text(onestore::page::TextObject { | 143 | content: ParagraphContent::Text(onestore::page::TextObject { |
| 130 | id: file.id, | 144 | id: text_id, |
| 131 | date_field: None, | 145 | date_field: None, |
| 132 | text: Paragraph::new( | 146 | text: Paragraph::new(text.into(), format), |
| 133 | name.into(), | ||
| 134 | Format { | ||
| 135 | alignment: Some(1), | ||
| 136 | ..node.format.clone() | ||
| 137 | }, | ||
| 138 | ), | ||
| 139 | tags: Vec::new(), | 147 | tags: Vec::new(), |
| 140 | }), | 148 | }), |
| 141 | lists: Vec::new(), | 149 | lists: Vec::new(), |
| ... | @@ -465,7 +473,7 @@ impl OutlineLayout { | ... | @@ -465,7 +473,7 @@ impl OutlineLayout { |
| 465 | *value += offset; | 473 | *value += offset; |
| 466 | } | 474 | } |
| 467 | object.bottom += origin[1]; | 475 | object.bottom += origin[1]; |
| 468 | if let Some(label) = &mut object.label { | 476 | if let ObjectKind::File(label) | ObjectKind::Unsupported(label) = &mut object.kind { |
| 469 | label.reset_origin(label.origin[0] + origin[0]); | 477 | label.reset_origin(label.origin[0] + origin[0]); |
| 470 | label.origin[1] += origin[1]; | 478 | label.origin[1] += origin[1]; |
| 471 | } | 479 | } |
| ... | @@ -528,8 +536,7 @@ impl OutlineLayout { | ... | @@ -528,8 +536,7 @@ impl OutlineLayout { |
| 528 | result.objects.push(ObjectLayout { | 536 | result.objects.push(ObjectLayout { |
| 529 | id: image.id, | 537 | id: image.id, |
| 530 | rect: [x, y, x + w, y + h], | 538 | rect: [x, y, x + w, y + h], |
| 531 | label: None, | 539 | kind: ObjectKind::Picture, |
| 532 | ink: None, | ||
| 533 | bottom: y + h, | 540 | bottom: y + h, |
| 534 | }); | 541 | }); |
| 535 | result.size[0] = result.size[0].max(x + w); | 542 | result.size[0] = result.size[0].max(x + w); |
| ... | @@ -540,15 +547,27 @@ impl OutlineLayout { | ... | @@ -540,15 +547,27 @@ impl OutlineLayout { |
| 540 | let y = spacing(&mut bottom, &mut previous, &node.format)?; | 547 | let y = spacing(&mut bottom, &mut previous, &node.format)?; |
| 541 | let [w, h] = file.size.unwrap_or([24.0, 24.0]); | 548 | let [w, h] = file.size.unwrap_or([24.0, 24.0]); |
| 542 | let icon = [x + (ATTACHMENT_WIDTH - w) / 2.0, y + 6.0]; | 549 | let icon = [x + (ATTACHMENT_WIDTH - w) / 2.0, y + 6.0]; |
| 543 | let mut label = shape(&attachment_label(node, file), ATTACHMENT_WIDTH, &[0.0])?; | 550 | // OneNote shows the name without its extension. |
| 551 | let name = std::path::Path::new(&file.filename) | ||
| 552 | .file_stem() | ||
| 553 | .and_then(|stem| stem.to_str()) | ||
| 554 | .unwrap_or(&file.filename); | ||
| 555 | let format = Format { | ||
| 556 | alignment: Some(1), | ||
| 557 | ..node.format.clone() | ||
| 558 | }; | ||
| 559 | let mut label = shape( | ||
| 560 | &caption(node.id, file.id, name, format), | ||
| 561 | ATTACHMENT_WIDTH, | ||
| 562 | &[0.0], | ||
| 563 | )?; | ||
| 544 | label.reset_origin(x); | 564 | label.reset_origin(x); |
| 545 | label.origin[1] = icon[1] + h + 10.5; | 565 | label.origin[1] = icon[1] + h + 10.5; |
| 546 | let end = label.origin[1] + label.text.height() + 9.0; | 566 | let end = label.origin[1] + label.text.height() + 9.0; |
| 547 | result.objects.push(ObjectLayout { | 567 | result.objects.push(ObjectLayout { |
| 548 | id: file.id, | 568 | id: file.id, |
| 549 | rect: [icon[0], icon[1], icon[0] + w, icon[1] + h], | 569 | rect: [icon[0], icon[1], icon[0] + w, icon[1] + h], |
| 550 | label: Some(label), | 570 | kind: ObjectKind::File(label), |
| 551 | ink: None, | ||
| 552 | bottom: end, | 571 | bottom: end, |
| 553 | }); | 572 | }); |
| 554 | result.size[0] = result.size[0].max(x + ATTACHMENT_WIDTH); | 573 | result.size[0] = result.size[0].max(x + ATTACHMENT_WIDTH); |
| ... | @@ -566,15 +585,41 @@ impl OutlineLayout { | ... | @@ -566,15 +585,41 @@ impl OutlineLayout { |
| 566 | result.objects.push(ObjectLayout { | 585 | result.objects.push(ObjectLayout { |
| 567 | id: ink.id, | 586 | id: ink.id, |
| 568 | rect: [x, y, x + w, y + h], | 587 | rect: [x, y, x + w, y + h], |
| 569 | label: None, | 588 | kind: ObjectKind::Ink(ink.clone()), |
| 570 | ink: Some(ink.clone()), | ||
| 571 | bottom: y + h, | 589 | bottom: y + h, |
| 572 | }); | 590 | }); |
| 573 | result.size[0] = result.size[0].max(x + w); | 591 | result.size[0] = result.size[0].max(x + w); |
| 574 | bottom += f64::from(h); | 592 | bottom += f64::from(h); |
| 575 | } | 593 | } |
| 576 | ParagraphContent::Unsupported(_) => { | 594 | ParagraphContent::Unsupported(unsupported) => { |
| 577 | return Err(LayoutError::UnsupportedContent); | 595 | let x = indentation(node.level, indents, width)?; |
| 596 | let y = spacing(&mut bottom, &mut previous, &node.format)?; | ||
| 597 | let w = unsupported.layout.max_width.unwrap_or(160.0).max(160.0); | ||
| 598 | let mut label = shape( | ||
| 599 | &caption( | ||
| 600 | node.id, | ||
| 601 | unsupported.id, | ||
| 602 | "Unsupported content", | ||
| 603 | Format::default(), | ||
| 604 | ), | ||
| 605 | w - 16.0, | ||
| 606 | &[0.0], | ||
| 607 | )?; | ||
| 608 | label.reset_origin(x + 8.0); | ||
| 609 | label.origin[1] = y + 8.0; | ||
| 610 | let h = unsupported | ||
| 611 | .layout | ||
| 612 | .max_height | ||
| 613 | .unwrap_or(0.0) | ||
| 614 | .max(label.text.height() + 16.0); | ||
| 615 | result.objects.push(ObjectLayout { | ||
| 616 | id: unsupported.id, | ||
| 617 | rect: [x, y, x + w, y + h], | ||
| 618 | kind: ObjectKind::Unsupported(label), | ||
| 619 | bottom: y + h, | ||
| 620 | }); | ||
| 621 | result.size[0] = result.size[0].max(x + w); | ||
| 622 | bottom += f64::from(h); | ||
| 578 | } | 623 | } |
| 579 | } | 624 | } |
| 580 | if !(bottom as f32).is_finite() || !result.size[0].is_finite() { | 625 | if !(bottom as f32).is_finite() || !result.size[0].is_finite() { |
| ... | @@ -1257,7 +1302,7 @@ mod tests { | ... | @@ -1257,7 +1302,7 @@ mod tests { |
| 1257 | let top = layout.paragraphs[0].text.height(); | 1302 | let top = layout.paragraphs[0].text.height(); |
| 1258 | let [icon, handwriting] = [&layout.objects[0], &layout.objects[1]]; | 1303 | let [icon, handwriting] = [&layout.objects[0], &layout.objects[1]]; |
| 1259 | assert_eq!(icon.rect, [15.0, top + 6.0, 39.0, top + 30.0]); | 1304 | assert_eq!(icon.rect, [15.0, top + 6.0, 39.0, top + 30.0]); |
| 1260 | let label = icon.label.as_ref().unwrap(); | 1305 | let label = icon.label().unwrap(); |
| 1261 | assert_eq!(label.projection.text().text(), "notes 🦀"); | 1306 | assert_eq!(label.projection.text().text(), "notes 🦀"); |
| 1262 | assert_eq!(label.origin, [0.0, top + 40.5]); | 1307 | assert_eq!(label.origin, [0.0, top + 40.5]); |
| 1263 | assert_eq!(icon.bottom, label.origin[1] + label.text.height() + 9.0); | 1308 | assert_eq!(icon.bottom, label.origin[1] + label.text.height() + 9.0); |
tools/canvas/README.md+1-1| ... | @@ -133,7 +133,7 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA | ... | @@ -133,7 +133,7 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA |
| 133 | 133 | ||
| 134 | 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. | 134 | 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. |
| 135 | 135 | ||
| 136 | Unsupported top-level objects, outlines or titles 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. | 136 | 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. |
| 137 | 137 | ||
| 138 | Placeholders use source positions and at least 160 pt width, expanding their height to contain the status text. This is an explicit substitute for unavailable rendering, not a geometric reconstruction. Pointer presses focus placeholders, hide the previous text caret/selection and retire its IME composition. Typing, deletion, paste, text-selection commands, undo/redo and outline movement are suppressed while a placeholder has focus. Escape restores the previous text focus; Ctrl-Tab cycles through text outlines and placeholders. Zoom remains available, and Cmd-Shift-N creates an editable annotation beside the focused object. AccessKit exposes placeholder focus, status and bounds without text-edit actions; identities persist across editor updates and viewport changes. Focus reveals offscreen placeholders and suspends caret-blink scheduling. Hit-testing follows paint order for overlapping editable outlines and placeholders: new annotations draw last and receive hits first, while imported objects resolve from front to back. Each body outline’s header, width grips and text participate at its own position in that order before any outline’s padding or tag gutters, so a width handle stays reachable under the left padding of the outline above it. Supported titles expose editable text without move/resize handles. Pictures other than backgrounds take hits at their paint position, and a selected picture's handles take hits above everything else. Invalid geometry, malformed image data and resource-budget failures remain errors. | 138 | Placeholders use source positions and at least 160 pt width, expanding their height to contain the status text. This is an explicit substitute for unavailable rendering, not a geometric reconstruction. Pointer presses focus placeholders, hide the previous text caret/selection and retire its IME composition. Typing, deletion, paste, text-selection commands, undo/redo and outline movement are suppressed while a placeholder has focus. Escape restores the previous text focus; Ctrl-Tab cycles through text outlines and placeholders. Zoom remains available, and Cmd-Shift-N creates an editable annotation beside the focused object. AccessKit exposes placeholder focus, status and bounds without text-edit actions; identities persist across editor updates and viewport changes. Focus reveals offscreen placeholders and suspends caret-blink scheduling. Hit-testing follows paint order for overlapping editable outlines and placeholders: new annotations draw last and receive hits first, while imported objects resolve from front to back. Each body outline’s header, width grips and text participate at its own position in that order before any outline’s padding or tag gutters, so a width handle stays reachable under the left padding of the outline above it. Supported titles expose editable text without move/resize handles. Pictures other than backgrounds take hits at their paint position, and a selected picture's handles take hits above everything else. Invalid geometry, malformed image data and resource-budget failures remain errors. |
| 139 | 139 |