| 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 | 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 | 178 | ParagraphContent::Table(table) => { |
| 175 | 179 | if !ids.insert(table.id) |
| 176 | 180 | || table.rows.is_empty() |
| ... | ... | @@ -942,7 +946,7 @@ mod tests { |
| 942 | 946 | } |
| 943 | 947 | |
| 944 | 948 | #[test] |
| 945 | fn replacement_cannot_alias_existing_objects_or_publish_unsupported_nodes() { | |
| 949 | fn replacement_cannot_alias_existing_objects() { | |
| 946 | 950 | let mut document = |
| 947 | 951 | TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(); |
| 948 | 952 | let original = document.clone(); |
| ... | ... | @@ -960,18 +964,14 @@ mod tests { |
| 960 | 964 | jcid: 0x60012, |
| 961 | 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 | 972 | assert_eq!( |
| 964 | TextDocument::from_nodes(unsupported.clone()), | |
| 965 | Err(EditError::UnsupportedContent) | |
| 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) | |
| 973 | TextDocument::from_nodes(twice), | |
| 974 | Err(EditError::InvalidStructure) | |
| 975 | 975 | ); |
| 976 | 976 | assert_eq!(document, original); |
| 977 | 977 | } |
crates/canvas/src/editor.rs+85-20| ... | ... | @@ -255,6 +255,10 @@ impl TextOutline { |
| 255 | 255 | outline.layout(engine, definitions)? |
| 256 | 256 | }; |
| 257 | 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 | 262 | Ok(Self { |
| 259 | 263 | id: outline.id, |
| 260 | 264 | title: outline.title, |
| ... | ... | @@ -458,10 +462,8 @@ impl TextOutline { |
| 458 | 462 | } |
| 459 | 463 | onestore::page::ParagraphContent::Image(_) |
| 460 | 464 | | onestore::page::ParagraphContent::Attachment(_) |
| 461 | | onestore::page::ParagraphContent::Ink(_) => {} | |
| 462 | onestore::page::ParagraphContent::Unsupported(_) => { | |
| 463 | return Err(EditError::UnsupportedContent); | |
| 464 | } | |
| 465 | | onestore::page::ParagraphContent::Ink(_) | |
| 466 | | onestore::page::ParagraphContent::Unsupported(_) => {} | |
| 465 | 467 | } |
| 466 | 468 | } |
| 467 | 469 | Ok(result) |
| ... | ... | @@ -508,13 +510,11 @@ impl TextOutline { |
| 508 | 510 | .rect[1] |
| 509 | 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 | 514 | onestore::page::ParagraphContent::Image(_) |
| 513 | 515 | | onestore::page::ParagraphContent::Attachment(_) |
| 514 | | onestore::page::ParagraphContent::Ink(_) => continue, | |
| 515 | onestore::page::ParagraphContent::Unsupported(_) => { | |
| 516 | return Err(EditError::UnsupportedContent); | |
| 517 | } | |
| 516 | | onestore::page::ParagraphContent::Ink(_) | |
| 517 | | onestore::page::ParagraphContent::Unsupported(_) => continue, | |
| 518 | 518 | }; |
| 519 | 519 | if target.is_none() || top <= y { |
| 520 | 520 | target = Some(node); |
| ... | ... | @@ -1829,16 +1829,16 @@ impl CanvasEditor { |
| 1829 | 1829 | ) |
| 1830 | 1830 | | onestore::page::ParagraphContent::Ink(onestore::page::Ink { |
| 1831 | 1831 | id, .. |
| 1832 | }) => outline | |
| 1832 | }) | |
| 1833 | | onestore::page::ParagraphContent::Unsupported( | |
| 1834 | onestore::page::Unsupported { id, .. }, | |
| 1835 | ) => outline | |
| 1833 | 1836 | .shaped |
| 1834 | 1837 | .objects |
| 1835 | 1838 | .iter() |
| 1836 | 1839 | .find(|object| object.id == *id) |
| 1837 | 1840 | .map(|object| f64::from(object.bottom)) |
| 1838 | 1841 | .ok_or(EditError::InvalidStructure)?, |
| 1839 | onestore::page::ParagraphContent::Unsupported(_) => { | |
| 1840 | return Err(EditError::UnsupportedContent.into()); | |
| 1841 | } | |
| 1842 | 1842 | } + f64::from(outline.origin()[1]); |
| 1843 | 1843 | let cell = (bottom - 14.4) / 18.0; |
| 1844 | 1844 | // Stored coordinates can straddle a grid boundary by one f32 ULP. |
| ... | ... | @@ -2848,13 +2848,7 @@ impl CanvasEditor { |
| 2848 | 2848 | .shaped |
| 2849 | 2849 | .paragraphs |
| 2850 | 2850 | .iter() |
| 2851 | .chain( | |
| 2852 | outline | |
| 2853 | .shaped | |
| 2854 | .objects | |
| 2855 | .iter() | |
| 2856 | .filter_map(|o| o.label.as_ref()), | |
| 2857 | ) | |
| 2851 | .chain(outline.shaped.objects.iter().filter_map(|o| o.label())) | |
| 2858 | 2852 | .map(|paragraph| (paragraph.id, paragraph)) |
| 2859 | 2853 | .collect::<BTreeMap<_, _>>(); |
| 2860 | 2854 | let shaped = OutlineLayout::flow( |
| ... | ... | @@ -4740,6 +4734,77 @@ mod tests { |
| 4740 | 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 | 4808 | #[test] |
| 4744 | 4809 | fn title_flow_moves_page_objects_atomically_and_cancels_composition() { |
| 4745 | 4810 | use onestore::page::{Image, Page, PageObject, Title}; |
crates/canvas/src/editor/page.rs+27-26| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | use super::{EditorError, TextOutline}; | |
| 1 | use super::{EditError, EditorError, TextOutline}; | |
| 2 | 2 | use crate::{ |
| 3 | 3 | date::PageDate, |
| 4 | 4 | layout::{LayoutError, TextEngine, TextLayout}, |
| ... | ... | @@ -153,39 +153,40 @@ pub(crate) fn build( |
| 153 | 153 | if origin.iter().any(|v| !v.is_finite()) { |
| 154 | 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 | 163 | if editable { |
| 157 | 164 | match TextOutline::from_outline(engine, outline, &page.definitions) { |
| 158 | 165 | Ok(outline) => { |
| 159 | 166 | objects.push(Content::Editable(outline.id)); |
| 160 | 167 | outlines.push(outline); |
| 168 | continue; | |
| 161 | 169 | } |
| 162 | Err(EditorError::Layout(LayoutError::UnsupportedContent)) => { | |
| 163 | objects.push(Content::ReadOnly(ReadOnlyObject::new( | |
| 164 | object, | |
| 165 | page.margin_origin, | |
| 166 | "Unsupported content\nRead-only", | |
| 167 | engine, | |
| 168 | )?)) | |
| 169 | } | |
| 170 | Err(error) => return Err(error), | |
| 170 | Err(error) if !unsupported(&error) => return Err(error), | |
| 171 | // Drawn as stored when the editor cannot hold it. | |
| 172 | Err(_) => {} | |
| 171 | 173 | } |
| 172 | } else { | |
| 173 | match outline.layout(engine, &page.definitions) { | |
| 174 | Ok(layout) => objects.push(Content::Outline { | |
| 175 | source: outline.clone(), | |
| 176 | layout, | |
| 177 | below_title: None, | |
| 178 | }), | |
| 179 | Err(LayoutError::UnsupportedContent) => { | |
| 180 | objects.push(Content::ReadOnly(ReadOnlyObject::new( | |
| 181 | object, | |
| 182 | page.margin_origin, | |
| 183 | "Unsupported content\nRead-only", | |
| 184 | engine, | |
| 185 | )?)) | |
| 186 | } | |
| 187 | Err(error) => return Err(error.into()), | |
| 174 | } | |
| 175 | match outline.layout(engine, &page.definitions) { | |
| 176 | Ok(layout) => objects.push(Content::Outline { | |
| 177 | source: outline.clone(), | |
| 178 | layout, | |
| 179 | below_title: None, | |
| 180 | }), | |
| 181 | Err(LayoutError::UnsupportedContent) => { | |
| 182 | objects.push(Content::ReadOnly(ReadOnlyObject::new( | |
| 183 | object, | |
| 184 | page.margin_origin, | |
| 185 | "Unsupported content\nRead-only", | |
| 186 | engine, | |
| 187 | )?)) | |
| 188 | 188 | } |
| 189 | Err(error) => return Err(error.into()), | |
| 189 | 190 | } |
| 190 | 191 | } |
| 191 | 192 | PageObject::Title(title) => { |
crates/canvas/src/gpu/page.rs+24-17| ... | ... | @@ -220,26 +220,33 @@ impl PageScene { |
| 220 | 220 | primitives: &mut Vec<Primitive<'a>>, |
| 221 | 221 | ) { |
| 222 | 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 | 230 | if let Some(image) = self.images.get(&object.id) { |
| 224 | let [x0, y0, x1, y1] = object.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 | }); | |
| 231 | primitives.push(Primitive::Image { image, rect }); | |
| 234 | 232 | } |
| 235 | if let Some(ink) = &object.ink { | |
| 236 | append_ink( | |
| 237 | ink, | |
| 238 | [origin[0] + object.rect[0], origin[1] + object.rect[1]], | |
| 239 | primitives, | |
| 240 | ); | |
| 233 | match &object.kind { | |
| 234 | crate::outline::ObjectKind::Ink(ink) => { | |
| 235 | append_ink(ink, [rect[0], rect[1]], primitives) | |
| 236 | } | |
| 237 | crate::outline::ObjectKind::Unsupported(_) => { | |
| 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 | 250 | primitives.push(Primitive::Text { |
| 244 | 251 | layout: &label.text, |
| 245 | 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 | 23 | |
| 24 | 24 | #[derive(Clone)] |
| 25 | 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 | 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 | 29 | pub rect: [f32; 4], |
| 30 | /// A file's name, centered under its icon. | |
| 31 | pub label: Option<ParagraphLayout>, | |
| 32 | /// Handwriting, whose strokes are relative to the rect's top-left. | |
| 33 | pub ink: Option<onestore::page::Ink>, | |
| 30 | pub kind: ObjectKind, | |
| 34 | 31 | /// Outline-local bottom of the whole object, label included. |
| 35 | 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 | 55 | /// OneNote centers a file's icon and name in a column this wide. |
| 39 | 56 | const ATTACHMENT_WIDTH: f32 = 54.0; |
| 40 | 57 | |
| ... | ... | @@ -114,28 +131,19 @@ impl ParagraphTag { |
| 114 | 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. | |
| 118 | fn attachment_label(node: &PageParagraph, file: &onestore::page::Attachment) -> PageParagraph { | |
| 119 | let name = std::path::Path::new(&file.filename) | |
| 120 | .file_stem() | |
| 121 | .and_then(|stem| stem.to_str()) | |
| 122 | .unwrap_or(&file.filename); | |
| 134 | /// A one-run paragraph standing in for an object's caption, so it lays out through the same | |
| 135 | /// shaping (and caching) as the outline's text. | |
| 136 | fn caption(id: ExGuid, text_id: ExGuid, text: &str, format: Format) -> PageParagraph { | |
| 123 | 137 | PageParagraph { |
| 124 | id: node.id, | |
| 138 | id, | |
| 125 | 139 | parent: None, |
| 126 | 140 | level: 1, |
| 127 | 141 | style: None, |
| 128 | 142 | format: Format::default(), |
| 129 | 143 | content: ParagraphContent::Text(onestore::page::TextObject { |
| 130 | id: file.id, | |
| 144 | id: text_id, | |
| 131 | 145 | date_field: None, |
| 132 | text: Paragraph::new( | |
| 133 | name.into(), | |
| 134 | Format { | |
| 135 | alignment: Some(1), | |
| 136 | ..node.format.clone() | |
| 137 | }, | |
| 138 | ), | |
| 146 | text: Paragraph::new(text.into(), format), | |
| 139 | 147 | tags: Vec::new(), |
| 140 | 148 | }), |
| 141 | 149 | lists: Vec::new(), |
| ... | ... | @@ -465,7 +473,7 @@ impl OutlineLayout { |
| 465 | 473 | *value += offset; |
| 466 | 474 | } |
| 467 | 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 | 477 | label.reset_origin(label.origin[0] + origin[0]); |
| 470 | 478 | label.origin[1] += origin[1]; |
| 471 | 479 | } |
| ... | ... | @@ -528,8 +536,7 @@ impl OutlineLayout { |
| 528 | 536 | result.objects.push(ObjectLayout { |
| 529 | 537 | id: image.id, |
| 530 | 538 | rect: [x, y, x + w, y + h], |
| 531 | label: None, | |
| 532 | ink: None, | |
| 539 | kind: ObjectKind::Picture, | |
| 533 | 540 | bottom: y + h, |
| 534 | 541 | }); |
| 535 | 542 | result.size[0] = result.size[0].max(x + w); |
| ... | ... | @@ -540,15 +547,27 @@ impl OutlineLayout { |
| 540 | 547 | let y = spacing(&mut bottom, &mut previous, &node.format)?; |
| 541 | 548 | let [w, h] = file.size.unwrap_or([24.0, 24.0]); |
| 542 | 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 | 564 | label.reset_origin(x); |
| 545 | 565 | label.origin[1] = icon[1] + h + 10.5; |
| 546 | 566 | let end = label.origin[1] + label.text.height() + 9.0; |
| 547 | 567 | result.objects.push(ObjectLayout { |
| 548 | 568 | id: file.id, |
| 549 | 569 | rect: [icon[0], icon[1], icon[0] + w, icon[1] + h], |
| 550 | label: Some(label), | |
| 551 | ink: None, | |
| 570 | kind: ObjectKind::File(label), | |
| 552 | 571 | bottom: end, |
| 553 | 572 | }); |
| 554 | 573 | result.size[0] = result.size[0].max(x + ATTACHMENT_WIDTH); |
| ... | ... | @@ -566,15 +585,41 @@ impl OutlineLayout { |
| 566 | 585 | result.objects.push(ObjectLayout { |
| 567 | 586 | id: ink.id, |
| 568 | 587 | rect: [x, y, x + w, y + h], |
| 569 | label: None, | |
| 570 | ink: Some(ink.clone()), | |
| 588 | kind: ObjectKind::Ink(ink.clone()), | |
| 571 | 589 | bottom: y + h, |
| 572 | 590 | }); |
| 573 | 591 | result.size[0] = result.size[0].max(x + w); |
| 574 | 592 | bottom += f64::from(h); |
| 575 | 593 | } |
| 576 | ParagraphContent::Unsupported(_) => { | |
| 577 | return Err(LayoutError::UnsupportedContent); | |
| 594 | ParagraphContent::Unsupported(unsupported) => { | |
| 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 | 625 | if !(bottom as f32).is_finite() || !result.size[0].is_finite() { |
| ... | ... | @@ -1257,7 +1302,7 @@ mod tests { |
| 1257 | 1302 | let top = layout.paragraphs[0].text.height(); |
| 1258 | 1303 | let [icon, handwriting] = [&layout.objects[0], &layout.objects[1]]; |
| 1259 | 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 | 1306 | assert_eq!(label.projection.text().text(), "notes 🦀"); |
| 1262 | 1307 | assert_eq!(label.origin, [0.0, top + 40.5]); |
| 1263 | 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 | 133 | |
| 134 | 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 | 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 |