From 24d94846b5b33c2a832f3814bf0d80cf7a66cdde Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 11:54:15 -0700 Subject: [PATCH] fix: several lines pasted into a page title go into the body Pasting more than one line or paragraph into a title failed with "Title containers cannot be split". OneNote 2010 keeps a title to the one paragraph: several lines pasted there, plain or formatted, go where a picture pasted from the title goes (lab, 2026-10-02), at the end of the outline where the body starts, in a new outline there on an empty body, else in a new outline two grid rows below the content. A plain paste now also drops an empty half of the caret's paragraph, as a formatted one does and as OneNote did. Fixes #60. Assisted-by: claude-opus-5.5 --- crates/canvas/src/editor.rs | 71 +++++++++++------- crates/canvas/src/editor/clip.rs | 46 +++++++----- crates/canvas/tests/clip_paste.rs | 119 ++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 47 deletions(-) diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 995b670a5ef90c06b8612486164a9dfebe473bb9..cbc0f90af6ac1593fc8c2f02cf917b19fc4fd7ab 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -2313,41 +2313,50 @@ impl CanvasEditor { Ok(true) } + /// Moves the caret from the title to where OneNote 2010 puts what the title can't take + /// (lab, 2026-09-30 and 2026-10-02): the end of an outline where the body starts, a new + /// one there on a page without body content, and otherwise a caret on blank page two grid + /// rows below the content, which is the one case it returns false. + fn leave_title_for_body(&mut self, engine: &mut TextEngine) -> Result { + let start = self.body_start_of(self.active_outline()); + let first = self + .outlines + .iter() + .find(|outline| !outline.title && outline.origin() == start) + .map(|outline| outline.id); + if let Some(id) = first { + self.focus_outline(id)?; + self.move_selection(engine, Movement::DocumentEnd, false)?; + return Ok(true); + } + let Some(bottom) = self.body_bottom() else { + self.place_caret(engine, start, DEFAULT_OUTLINE_WIDTH)?; + return Ok(true); + }; + let margin = self.margin_origin(); + let row = ((bottom - margin[1]) / 18.0).floor() + 2.0; + self.place_caret( + engine, + [start[0], margin[1] + row * 18.0], + DEFAULT_OUTLINE_WIDTH, + )?; + Ok(false) + } + /// Puts `image` at the caret as OneNote 2010 pastes and inserts a picture: in an outline /// as [`Self::insert_attachment`] puts a file, and on blank page at the caret with the /// caret moving to the grid row below it, where typing starts a new outline. From the - /// title it goes at the end of an outline where the body starts, in a new one there on a - /// page without body content, and otherwise on the page two grid rows below the content - /// (lab, 2026-09-30). + /// title it goes where [`Self::leave_title_for_body`] puts the caret, in the flow there + /// unless on blank page. pub fn insert_picture( &mut self, engine: &mut TextEngine, mut image: onestore::page::Image, ) -> Result<(), EditorError> { - let margin = self.margin_origin(); - if self.active_outline().title { - let start = self.body_start_of(self.active_outline()); - let first = self - .outlines - .iter() - .find(|outline| !outline.title && outline.origin() == start) - .map(|outline| outline.id); - if let Some(id) = first { - self.focus_outline(id)?; - self.move_selection(engine, Movement::DocumentEnd, false)?; - return self.insert_in_flow(engine, ParagraphContent::Image(image)); - } - let Some(bottom) = self.body_bottom() else { - self.place_caret(engine, start, DEFAULT_OUTLINE_WIDTH)?; - return self.insert_in_flow(engine, ParagraphContent::Image(image)); - }; - let row = ((bottom - margin[1]) / 18.0).floor() + 2.0; - self.place_caret( - engine, - [start[0], margin[1] + row * 18.0], - DEFAULT_OUTLINE_WIDTH, - )?; + if self.active_outline().title && self.leave_title_for_body(engine)? { + return self.insert_in_flow(engine, ParagraphContent::Image(image)); } + let margin = self.margin_origin(); let Focus::Caret { outline, .. } = &self.active else { return self.insert_in_flow(engine, ParagraphContent::Image(image)); }; @@ -3562,7 +3571,9 @@ impl CanvasEditor { } /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style - /// or list between the halves of the caret's paragraph (`evidence/structural-edits/xml/c7-*`). + /// or list between the halves of the caret's paragraph (`evidence/structural-edits/xml/c7-*`), + /// an empty half dropped; from the title they go where [`Self::leave_title_for_body`] puts + /// the caret. /// Pasted runs take the clipboard's `language`, an LCID, not the caret's run's as typing /// does; Windows derives it from the keyboard language at copy time. pub fn paste( @@ -3582,6 +3593,9 @@ impl CanvasEditor { .split('\n') .map(|line| line.strip_suffix('\r').unwrap_or(line)) .collect::>(); + if lines.len() > 1 && self.active_outline().title { + self.leave_title_for_body(engine)?; + } let last = lines[lines.len() - 1]; let [anchor, focus] = self.active_outline().selection.positions; let (start, end) = (anchor.min(focus), anchor.max(focus)); @@ -3617,8 +3631,9 @@ impl CanvasEditor { node.style = None; node.lists.clear(); } + let head = clip::drop_empty_halves(&mut edit, lines.len(), true); let caret = TextPosition { - paragraph: start.paragraph + lines.len(), + paragraph: start.paragraph + lines.len() - usize::from(head), offset: u32::try_from(last.encode_utf16().count()) .map_err(|_| EditError::TextTooLong)?, }; diff --git a/crates/canvas/src/editor/clip.rs b/crates/canvas/src/editor/clip.rs index c37c524f6969d6b3d77dbf39af8737a712beb0ea..e750063d6744abb4e01c432f8fb4fae4bc59a2cb 100644 --- a/crates/canvas/src/editor/clip.rs +++ b/crates/canvas/src/editor/clip.rs @@ -92,7 +92,7 @@ impl CanvasEditor { /// Pastes `clip` as one undo step: one paragraph's runs go in at the selection; more /// paragraphs go between the halves of the caret's paragraph, each keeping its /// formatting, style, list, tags and indentation below that paragraph's. A title takes - /// the text alone. + /// one paragraph's text alone; more go into the body. pub fn paste_clip(&mut self, engine: &mut TextEngine, clip: Clip) -> Result<(), EditorError> { if self.page_selected() { return self.grouped(|editor| { @@ -100,7 +100,9 @@ impl CanvasEditor { editor.paste_clip(engine, clip) }); } - if self.active_outline().title { + if self.active_outline().title && clip.paragraphs.len() > 1 { + self.leave_title_for_body(engine)?; + } else if self.active_outline().title { // A title takes text alone, as OneNote 2010's does (lab, 2026-10-02). let language = leaves(&clip.paragraphs, None) .find_map(|(.., node)| node.text()?.text.spans()[0].format.language) @@ -153,23 +155,7 @@ impl CanvasEditor { let count = nodes.len(); let added = leaves(&nodes, None).count(); edit.replacement.splice(1..=count, nodes); - // A half of the caret's paragraph goes where empty, as OneNote 2010 pastes (lab, - // 2026-10-02), unless it holds children or the caret. - let gone = |edit: &DocumentEdit, at: usize| { - let node = &edit.replacement[at]; - node.text().is_some_and(|text| text.text.text().is_empty()) - && !edit - .replacement - .iter() - .any(|child| child.parent == Some(node.id)) - }; - if ends_in_text && gone(&edit, count + 1) { - edit.replacement.remove(count + 1); - } - let head = gone(&edit, 0); - if head { - edit.replacement.remove(0); - } + let head = drop_empty_halves(&mut edit, count, ends_in_text); let paragraph = start.paragraph + added - usize::from(head); let caret = if ends_in_text { TextPosition { @@ -207,6 +193,28 @@ impl CanvasEditor { } } +/// Drops a half of the caret's paragraph around `count` pasted nodes where empty, as OneNote +/// 2010 pastes (lab, 2026-10-02), unless it holds children or, past what `ends_in_text` +/// pasted, the caret; true if the upper half went. +pub(super) fn drop_empty_halves(edit: &mut DocumentEdit, count: usize, ends_in_text: bool) -> bool { + let gone = |edit: &DocumentEdit, at: usize| { + let node = &edit.replacement[at]; + node.text().is_some_and(|text| text.text.text().is_empty()) + && !edit + .replacement + .iter() + .any(|child| child.parent == Some(node.id)) + }; + if ends_in_text && gone(edit, count + 1) { + edit.replacement.remove(count + 1); + } + let head = gone(edit, 0); + if head { + edit.replacement.remove(0); + } + head +} + /// Gives `nodes` and everything they hold new identities, keeping their tree, and points /// their definitions at `renamed`'s, dropping what names none; a cell without indents takes /// `indents`. diff --git a/crates/canvas/tests/clip_paste.rs b/crates/canvas/tests/clip_paste.rs index 9193fdb60045a3d0bec882c6427458167f555a39..632691524ff9f0959e3b651eb0d9dfe76d421501 100644 --- a/crates/canvas/tests/clip_paste.rs +++ b/crates/canvas/tests/clip_paste.rs @@ -191,3 +191,122 @@ fn a_title_takes_the_text_alone() { .unwrap(); assert_eq!(title.shown_text(), "Bold title"); } + +/// Several lines pasted from the title go into the body as OneNote 2010 puts them (lab, +/// 2026-10-02), plain or formatted, and the title keeps its text: at the end of the outline +/// where the body starts, in a new one there on a page without body content, and otherwise +/// in a new one two grid rows below the content. +#[test] +fn several_lines_pasted_from_the_title_go_into_the_body() { + let source = onestore::create_section("clips.one", "First page", "Author").unwrap(); + let arena = Arena::default(); + let mut section = Section::open(&arena, source.clone()).unwrap(); + let mut at = 133_000_000_000_000_000; + let mut spaces = Vec::new(); + for title in ["Title paste", "Far content"] { + let creation = onestore::PageCreation::new(None, Some(title), "Author").unwrap(); + let create = Op::Section(onestore::op::SectionOp::Create(creation)); + at += 10_000_000; + section + .apply( + "Author", + &Edit { + at, + ops: vec![create], + }, + ) + .unwrap(); + spaces.push(section.pages().unwrap().last().unwrap().0); + } + let mut engine = TextEngine::default(); + let mut store = |section: &mut Section, editor: &mut CanvasEditor, space| { + let ops = editor.take_ops().unwrap(); + assert!(!ops.is_empty()); + at += 10_000_000; + let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); + section.apply("Author", &Edit { at, ops }).unwrap(); + }; + let title = |editor: &mut CanvasEditor| { + let title = editor.outlines().iter().find(|o| o.title).unwrap().id; + editor.focus_outline(title).unwrap(); + }; + let origins = |editor: &CanvasEditor| { + editor + .outlines() + .iter() + .filter(|outline| !outline.title) + .map(|outline| outline.origin()) + .collect::>() + }; + + let page = section.page(spaces[0]).unwrap(); + let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); + let start = editor.body_start().unwrap(); + title(&mut editor); + editor + .paste( + &mut engine, + "First line\r\nSecond line\r\nThird line", + 0x409, + ) + .unwrap(); + store(&mut section, &mut editor, spaces[0]); + title(&mut editor); + editor.paste(&mut engine, "Alpha\r\nBeta", 0x409).unwrap(); + store(&mut section, &mut editor, spaces[0]); + assert_eq!(origins(&editor), [start]); + + let page = section.page(spaces[1]).unwrap(); + let mut far = CanvasEditor::from_page(page, &mut engine).unwrap(); + let margin = far.margin_origin(); + far.place_caret(&mut engine, [start[0] + 270.0, start[1] + 72.0], 468.0) + .unwrap(); + far.insert(&mut engine, "far").unwrap(); + let bottom = far.active_outline().bounds().y1 as f32; + title(&mut far); + let pieces = html_pieces("

One

  • Two
", 0x409, |_, _| None); + far.paste_pieces(&mut engine, pieces).unwrap(); + store(&mut section, &mut far, spaces[1]); + let row = ((bottom - margin[1]) / 18.0).floor() + 2.0; + assert_eq!( + origins(&far), + [ + [start[0] + 270.0, start[1] + 72.0], + [start[0], margin[1] + row * 18.0] + ] + ); + + let mut image = source; + section.seal().unwrap().unwrap().apply(&mut image).unwrap(); + let arena = Arena::default(); + let reopened = Section::open(&arena, image.clone()).unwrap(); + let page = reopened.page(spaces[0]).unwrap(); + assert_eq!(page.title, "Title paste"); + assert_eq!( + bodies(&page), + [vec![ + "First line", + "Second line", + "Third line", + "Alpha", + "Beta" + ]] + ); + assert_eq!(bodies(&page), bodies(&editor.page().unwrap())); + let page = reopened.page(spaces[1]).unwrap(); + assert_eq!(page.title, "Far content"); + assert_eq!(bodies(&page), [vec!["far"], vec!["One (bold)", "• Two"]]); + + if let Some(directory) = std::env::var_os("CANVAS_TITLE_PASTE_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("clips.one"), &image).unwrap(); + let file_id = Store::parse(&image).unwrap().header.file_id; + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents("Open Notebook.onetoc2", &[("clips.one", file_id)]) + .unwrap(), + ) + .unwrap(); + } +} -- 2.54.0