authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-24 11:14:45-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:21-07:00
logb822df2242bfd846f6475e49af173e4736187b4b
tree8d8e0b08b4b157bbec9db34f9c153d091873b218
parent5b135a22151997c7b480c1e65db0c2d85ad41712
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: keep outlines editable around content the canvas cannot draw

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.5

6 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 }
943947
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.86511 - 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 }) => outline1832 })
1833 | onestore::page::ParagraphContent::Unsupported(
1834 onestore::page::Unsupported { id, .. },
1835 ) => outline
1833 .shaped1836 .shaped
1834 .objects1837 .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 .shaped2848 .shaped
2849 .paragraphs2849 .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 }
47424736
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 @@
1use super::{EditorError, TextOutline};1use super::{EditError, EditorError, TextOutline};
2use crate::{2use 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 {
2323
24#[derive(Clone)]24#[derive(Clone)]
25pub struct ObjectLayout {25pub 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}
3734
35#[derive(Clone)]
36pub 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
46impl 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.
39const ATTACHMENT_WIDTH: f32 = 54.0;56const ATTACHMENT_WIDTH: f32 = 54.0;
4057
...@@ -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}
116133
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
118fn 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)136fn 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
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: 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
136Unsupported 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.136A 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
138Placeholders 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.138Placeholders 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.
139139