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(
170170 return Err(EditError::InvalidStructure);
171171 }
172172 }
173 ParagraphContent::Unsupported(_) => return Err(EditError::UnsupportedContent),
173 ParagraphContent::Unsupported(unsupported) => {
174 if !ids.insert(unsupported.id) {
175 return Err(EditError::InvalidStructure);
176 }
177 }
174178 ParagraphContent::Table(table) => {
175179 if !ids.insert(table.id)
176180 || table.rows.is_empty()
......@@ -942,7 +946,7 @@ mod tests {
942946 }
943947
944948 #[test]
945 fn replacement_cannot_alias_existing_objects_or_publish_unsupported_nodes() {
949 fn replacement_cannot_alias_existing_objects() {
946950 let mut document =
947951 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap();
948952 let original = document.clone();
......@@ -960,18 +964,14 @@ mod tests {
960964 jcid: 0x60012,
961965 layout: Default::default(),
962966 });
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);
963972 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)
975975 );
976976 assert_eq!(document, original);
977977 }
crates/canvas/src/editor.rs+85-20
......@@ -255,6 +255,10 @@ impl TextOutline {
255255 outline.layout(engine, definitions)?
256256 };
257257 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 }
258262 Ok(Self {
259263 id: outline.id,
260264 title: outline.title,
......@@ -458,10 +462,8 @@ impl TextOutline {
458462 }
459463 onestore::page::ParagraphContent::Image(_)
460464 | 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(_) => {}
465467 }
466468 }
467469 Ok(result)
......@@ -508,13 +510,11 @@ impl TextOutline {
508510 .rect[1]
509511 - 1.86
510512 }
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.
512514 onestore::page::ParagraphContent::Image(_)
513515 | 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,
518518 };
519519 if target.is_none() || top <= y {
520520 target = Some(node);
......@@ -1829,16 +1829,16 @@ impl CanvasEditor {
18291829 )
18301830 | onestore::page::ParagraphContent::Ink(onestore::page::Ink {
18311831 id, ..
1832 }) => outline
1832 })
1833 | onestore::page::ParagraphContent::Unsupported(
1834 onestore::page::Unsupported { id, .. },
1835 ) => outline
18331836 .shaped
18341837 .objects
18351838 .iter()
18361839 .find(|object| object.id == *id)
18371840 .map(|object| f64::from(object.bottom))
18381841 .ok_or(EditError::InvalidStructure)?,
1839 onestore::page::ParagraphContent::Unsupported(_) => {
1840 return Err(EditError::UnsupportedContent.into());
1841 }
18421842 } + f64::from(outline.origin()[1]);
18431843 let cell = (bottom - 14.4) / 18.0;
18441844 // Stored coordinates can straddle a grid boundary by one f32 ULP.
......@@ -2848,13 +2848,7 @@ impl CanvasEditor {
28482848 .shaped
28492849 .paragraphs
28502850 .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()))
28582852 .map(|paragraph| (paragraph.id, paragraph))
28592853 .collect::<BTreeMap<_, _>>();
28602854 let shaped = OutlineLayout::flow(
......@@ -4740,6 +4734,77 @@ mod tests {
47404734 assert_eq!(editor.active_outline().shaped().objects.len(), 1);
47414735 }
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
47434808 #[test]
47444809 fn title_flow_moves_page_objects_atomically_and_cancels_composition() {
47454810 use onestore::page::{Image, Page, PageObject, Title};
crates/canvas/src/editor/page.rs+27-26
......@@ -1,4 +1,4 @@
1use super::{EditorError, TextOutline};
1use super::{EditError, EditorError, TextOutline};
22use crate::{
33 date::PageDate,
44 layout::{LayoutError, TextEngine, TextLayout},
......@@ -153,39 +153,40 @@ pub(crate) fn build(
153153 if origin.iter().any(|v| !v.is_finite()) {
154154 return Err(EditorError::InvalidGeometry);
155155 }
156 let unsupported = |error: &EditorError| {
157 matches!(
158 error,
159 EditorError::Layout(LayoutError::UnsupportedContent)
160 | EditorError::Edit(EditError::UnsupportedContent)
161 )
162 };
156163 if editable {
157164 match TextOutline::from_outline(engine, outline, &page.definitions) {
158165 Ok(outline) => {
159166 objects.push(Content::Editable(outline.id));
160167 outlines.push(outline);
168 continue;
161169 }
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(_) => {}
171173 }
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 )?))
188188 }
189 Err(error) => return Err(error.into()),
189190 }
190191 }
191192 PageObject::Title(title) => {
crates/canvas/src/gpu/page.rs+24-17
......@@ -220,26 +220,33 @@ impl PageScene {
220220 primitives: &mut Vec<Primitive<'a>>,
221221 ) {
222222 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 ];
223230 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 });
234232 }
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(_) => {}
241248 }
242 if let Some(label) = &object.label {
249 if let Some(label) = object.label() {
243250 primitives.push(Primitive::Text {
244251 layout: &label.text,
245252 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 {
2323
2424#[derive(Clone)]
2525pub 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.
2727 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.
2929 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,
3431 /// Outline-local bottom of the whole object, label included.
3532 pub bottom: f32,
3633}
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
3855/// OneNote centers a file's icon and name in a column this wide.
3956const ATTACHMENT_WIDTH: f32 = 54.0;
4057
......@@ -114,28 +131,19 @@ impl ParagraphTag {
114131 pub const SIZE: f32 = 12.0;
115132}
116133
117/// The centered caption OneNote draws under a file's icon: its name without the extension.
118fn 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.
136fn caption(id: ExGuid, text_id: ExGuid, text: &str, format: Format) -> PageParagraph {
123137 PageParagraph {
124 id: node.id,
138 id,
125139 parent: None,
126140 level: 1,
127141 style: None,
128142 format: Format::default(),
129143 content: ParagraphContent::Text(onestore::page::TextObject {
130 id: file.id,
144 id: text_id,
131145 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),
139147 tags: Vec::new(),
140148 }),
141149 lists: Vec::new(),
......@@ -465,7 +473,7 @@ impl OutlineLayout {
465473 *value += offset;
466474 }
467475 object.bottom += origin[1];
468 if let Some(label) = &mut object.label {
476 if let ObjectKind::File(label) | ObjectKind::Unsupported(label) = &mut object.kind {
469477 label.reset_origin(label.origin[0] + origin[0]);
470478 label.origin[1] += origin[1];
471479 }
......@@ -528,8 +536,7 @@ impl OutlineLayout {
528536 result.objects.push(ObjectLayout {
529537 id: image.id,
530538 rect: [x, y, x + w, y + h],
531 label: None,
532 ink: None,
539 kind: ObjectKind::Picture,
533540 bottom: y + h,
534541 });
535542 result.size[0] = result.size[0].max(x + w);
......@@ -540,15 +547,27 @@ impl OutlineLayout {
540547 let y = spacing(&mut bottom, &mut previous, &node.format)?;
541548 let [w, h] = file.size.unwrap_or([24.0, 24.0]);
542549 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 )?;
544564 label.reset_origin(x);
545565 label.origin[1] = icon[1] + h + 10.5;
546566 let end = label.origin[1] + label.text.height() + 9.0;
547567 result.objects.push(ObjectLayout {
548568 id: file.id,
549569 rect: [icon[0], icon[1], icon[0] + w, icon[1] + h],
550 label: Some(label),
551 ink: None,
570 kind: ObjectKind::File(label),
552571 bottom: end,
553572 });
554573 result.size[0] = result.size[0].max(x + ATTACHMENT_WIDTH);
......@@ -566,15 +585,41 @@ impl OutlineLayout {
566585 result.objects.push(ObjectLayout {
567586 id: ink.id,
568587 rect: [x, y, x + w, y + h],
569 label: None,
570 ink: Some(ink.clone()),
588 kind: ObjectKind::Ink(ink.clone()),
571589 bottom: y + h,
572590 });
573591 result.size[0] = result.size[0].max(x + w);
574592 bottom += f64::from(h);
575593 }
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);
578623 }
579624 }
580625 if !(bottom as f32).is_finite() || !result.size[0].is_finite() {
......@@ -1257,7 +1302,7 @@ mod tests {
12571302 let top = layout.paragraphs[0].text.height();
12581303 let [icon, handwriting] = [&layout.objects[0], &layout.objects[1]];
12591304 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();
12611306 assert_eq!(label.projection.text().text(), "notes 🦀");
12621307 assert_eq!(label.origin, [0.0, top + 40.5]);
12631308 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
133133
134134Pictures, 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
138138Placeholders 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