authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 11:54:15-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 12:59:38-07:00
log24d94846b5b33c2a832f3814bf0d80cf7a66cdde
tree6b3e24e404ac03c37f4c93d345e127f47f027be0
parentbc754925884fa44f7244701711aa28b4b1042437
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

3 files changed, 189 insertions(+), 47 deletions(-)

crates/canvas/src/editor.rs+43-28
...@@ -2313,41 +2313,50 @@ impl CanvasEditor {...@@ -2313,41 +2313,50 @@ impl CanvasEditor {
2313 Ok(true)2313 Ok(true)
2314 }2314 }
23152315
2316 /// Moves the caret from the title to where OneNote 2010 puts what the title can't take
2317 /// (lab, 2026-09-30 and 2026-10-02): the end of an outline where the body starts, a new
2318 /// one there on a page without body content, and otherwise a caret on blank page two grid
2319 /// rows below the content, which is the one case it returns false.
2320 fn leave_title_for_body(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
2321 let start = self.body_start_of(self.active_outline());
2322 let first = self
2323 .outlines
2324 .iter()
2325 .find(|outline| !outline.title && outline.origin() == start)
2326 .map(|outline| outline.id);
2327 if let Some(id) = first {
2328 self.focus_outline(id)?;
2329 self.move_selection(engine, Movement::DocumentEnd, false)?;
2330 return Ok(true);
2331 }
2332 let Some(bottom) = self.body_bottom() else {
2333 self.place_caret(engine, start, DEFAULT_OUTLINE_WIDTH)?;
2334 return Ok(true);
2335 };
2336 let margin = self.margin_origin();
2337 let row = ((bottom - margin[1]) / 18.0).floor() + 2.0;
2338 self.place_caret(
2339 engine,
2340 [start[0], margin[1] + row * 18.0],
2341 DEFAULT_OUTLINE_WIDTH,
2342 )?;
2343 Ok(false)
2344 }
2345
2316 /// Puts `image` at the caret as OneNote 2010 pastes and inserts a picture: in an outline2346 /// Puts `image` at the caret as OneNote 2010 pastes and inserts a picture: in an outline
2317 /// as [`Self::insert_attachment`] puts a file, and on blank page at the caret with the2347 /// as [`Self::insert_attachment`] puts a file, and on blank page at the caret with the
2318 /// caret moving to the grid row below it, where typing starts a new outline. From the2348 /// caret moving to the grid row below it, where typing starts a new outline. From the
2319 /// title it goes at the end of an outline where the body starts, in a new one there on a2349 /// title it goes where [`Self::leave_title_for_body`] puts the caret, in the flow there
2320 /// page without body content, and otherwise on the page two grid rows below the content2350 /// unless on blank page.
2321 /// (lab, 2026-09-30).
2322 pub fn insert_picture(2351 pub fn insert_picture(
2323 &mut self,2352 &mut self,
2324 engine: &mut TextEngine,2353 engine: &mut TextEngine,
2325 mut image: onestore::page::Image,2354 mut image: onestore::page::Image,
2326 ) -> Result<(), EditorError> {2355 ) -> Result<(), EditorError> {
2327 let margin = self.margin_origin();2356 if self.active_outline().title && self.leave_title_for_body(engine)? {
2328 if self.active_outline().title {2357 return self.insert_in_flow(engine, ParagraphContent::Image(image));
2329 let start = self.body_start_of(self.active_outline());
2330 let first = self
2331 .outlines
2332 .iter()
2333 .find(|outline| !outline.title && outline.origin() == start)
2334 .map(|outline| outline.id);
2335 if let Some(id) = first {
2336 self.focus_outline(id)?;
2337 self.move_selection(engine, Movement::DocumentEnd, false)?;
2338 return self.insert_in_flow(engine, ParagraphContent::Image(image));
2339 }
2340 let Some(bottom) = self.body_bottom() else {
2341 self.place_caret(engine, start, DEFAULT_OUTLINE_WIDTH)?;
2342 return self.insert_in_flow(engine, ParagraphContent::Image(image));
2343 };
2344 let row = ((bottom - margin[1]) / 18.0).floor() + 2.0;
2345 self.place_caret(
2346 engine,
2347 [start[0], margin[1] + row * 18.0],
2348 DEFAULT_OUTLINE_WIDTH,
2349 )?;
2350 }2358 }
2359 let margin = self.margin_origin();
2351 let Focus::Caret { outline, .. } = &self.active else {2360 let Focus::Caret { outline, .. } = &self.active else {
2352 return self.insert_in_flow(engine, ParagraphContent::Image(image));2361 return self.insert_in_flow(engine, ParagraphContent::Image(image));
2353 };2362 };
...@@ -3562,7 +3571,9 @@ impl CanvasEditor {...@@ -3562,7 +3571,9 @@ impl CanvasEditor {
3562 }3571 }
35633572
3564 /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style3573 /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style
3565 /// or list between the halves of the caret's paragraph (`evidence/structural-edits/xml/c7-*`).3574 /// or list between the halves of the caret's paragraph (`evidence/structural-edits/xml/c7-*`),
3575 /// an empty half dropped; from the title they go where [`Self::leave_title_for_body`] puts
3576 /// the caret.
3566 /// Pasted runs take the clipboard's `language`, an LCID, not the caret's run's as typing3577 /// Pasted runs take the clipboard's `language`, an LCID, not the caret's run's as typing
3567 /// does; Windows derives it from the keyboard language at copy time.3578 /// does; Windows derives it from the keyboard language at copy time.
3568 pub fn paste(3579 pub fn paste(
...@@ -3582,6 +3593,9 @@ impl CanvasEditor {...@@ -3582,6 +3593,9 @@ impl CanvasEditor {
3582 .split('\n')3593 .split('\n')
3583 .map(|line| line.strip_suffix('\r').unwrap_or(line))3594 .map(|line| line.strip_suffix('\r').unwrap_or(line))
3584 .collect::<Vec<_>>();3595 .collect::<Vec<_>>();
3596 if lines.len() > 1 && self.active_outline().title {
3597 self.leave_title_for_body(engine)?;
3598 }
3585 let last = lines[lines.len() - 1];3599 let last = lines[lines.len() - 1];
3586 let [anchor, focus] = self.active_outline().selection.positions;3600 let [anchor, focus] = self.active_outline().selection.positions;
3587 let (start, end) = (anchor.min(focus), anchor.max(focus));3601 let (start, end) = (anchor.min(focus), anchor.max(focus));
...@@ -3617,8 +3631,9 @@ impl CanvasEditor {...@@ -3617,8 +3631,9 @@ impl CanvasEditor {
3617 node.style = None;3631 node.style = None;
3618 node.lists.clear();3632 node.lists.clear();
3619 }3633 }
3634 let head = clip::drop_empty_halves(&mut edit, lines.len(), true);
3620 let caret = TextPosition {3635 let caret = TextPosition {
3621 paragraph: start.paragraph + lines.len(),3636 paragraph: start.paragraph + lines.len() - usize::from(head),
3622 offset: u32::try_from(last.encode_utf16().count())3637 offset: u32::try_from(last.encode_utf16().count())
3623 .map_err(|_| EditError::TextTooLong)?,3638 .map_err(|_| EditError::TextTooLong)?,
3624 };3639 };
crates/canvas/src/editor/clip.rs+27-19
...@@ -92,7 +92,7 @@ impl CanvasEditor {...@@ -92,7 +92,7 @@ impl CanvasEditor {
92 /// Pastes `clip` as one undo step: one paragraph's runs go in at the selection; more92 /// Pastes `clip` as one undo step: one paragraph's runs go in at the selection; more
93 /// paragraphs go between the halves of the caret's paragraph, each keeping its93 /// paragraphs go between the halves of the caret's paragraph, each keeping its
94 /// formatting, style, list, tags and indentation below that paragraph's. A title takes94 /// formatting, style, list, tags and indentation below that paragraph's. A title takes
95 /// the text alone.95 /// one paragraph's text alone; more go into the body.
96 pub fn paste_clip(&mut self, engine: &mut TextEngine, clip: Clip) -> Result<(), EditorError> {96 pub fn paste_clip(&mut self, engine: &mut TextEngine, clip: Clip) -> Result<(), EditorError> {
97 if self.page_selected() {97 if self.page_selected() {
98 return self.grouped(|editor| {98 return self.grouped(|editor| {
...@@ -100,7 +100,9 @@ impl CanvasEditor {...@@ -100,7 +100,9 @@ impl CanvasEditor {
100 editor.paste_clip(engine, clip)100 editor.paste_clip(engine, clip)
101 });101 });
102 }102 }
103 if self.active_outline().title {103 if self.active_outline().title && clip.paragraphs.len() > 1 {
104 self.leave_title_for_body(engine)?;
105 } else if self.active_outline().title {
104 // A title takes text alone, as OneNote 2010's does (lab, 2026-10-02).106 // A title takes text alone, as OneNote 2010's does (lab, 2026-10-02).
105 let language = leaves(&clip.paragraphs, None)107 let language = leaves(&clip.paragraphs, None)
106 .find_map(|(.., node)| node.text()?.text.spans()[0].format.language)108 .find_map(|(.., node)| node.text()?.text.spans()[0].format.language)
...@@ -153,23 +155,7 @@ impl CanvasEditor {...@@ -153,23 +155,7 @@ impl CanvasEditor {
153 let count = nodes.len();155 let count = nodes.len();
154 let added = leaves(&nodes, None).count();156 let added = leaves(&nodes, None).count();
155 edit.replacement.splice(1..=count, nodes);157 edit.replacement.splice(1..=count, nodes);
156 // A half of the caret's paragraph goes where empty, as OneNote 2010 pastes (lab,158 let head = drop_empty_halves(&mut edit, count, ends_in_text);
157 // 2026-10-02), unless it holds children or the caret.
158 let gone = |edit: &DocumentEdit, at: usize| {
159 let node = &edit.replacement[at];
160 node.text().is_some_and(|text| text.text.text().is_empty())
161 && !edit
162 .replacement
163 .iter()
164 .any(|child| child.parent == Some(node.id))
165 };
166 if ends_in_text && gone(&edit, count + 1) {
167 edit.replacement.remove(count + 1);
168 }
169 let head = gone(&edit, 0);
170 if head {
171 edit.replacement.remove(0);
172 }
173 let paragraph = start.paragraph + added - usize::from(head);159 let paragraph = start.paragraph + added - usize::from(head);
174 let caret = if ends_in_text {160 let caret = if ends_in_text {
175 TextPosition {161 TextPosition {
...@@ -207,6 +193,28 @@ impl CanvasEditor {...@@ -207,6 +193,28 @@ impl CanvasEditor {
207 }193 }
208}194}
209195
196/// Drops a half of the caret's paragraph around `count` pasted nodes where empty, as OneNote
197/// 2010 pastes (lab, 2026-10-02), unless it holds children or, past what `ends_in_text`
198/// pasted, the caret; true if the upper half went.
199pub(super) fn drop_empty_halves(edit: &mut DocumentEdit, count: usize, ends_in_text: bool) -> bool {
200 let gone = |edit: &DocumentEdit, at: usize| {
201 let node = &edit.replacement[at];
202 node.text().is_some_and(|text| text.text.text().is_empty())
203 && !edit
204 .replacement
205 .iter()
206 .any(|child| child.parent == Some(node.id))
207 };
208 if ends_in_text && gone(edit, count + 1) {
209 edit.replacement.remove(count + 1);
210 }
211 let head = gone(edit, 0);
212 if head {
213 edit.replacement.remove(0);
214 }
215 head
216}
217
210/// Gives `nodes` and everything they hold new identities, keeping their tree, and points218/// Gives `nodes` and everything they hold new identities, keeping their tree, and points
211/// their definitions at `renamed`'s, dropping what names none; a cell without indents takes219/// their definitions at `renamed`'s, dropping what names none; a cell without indents takes
212/// `indents`.220/// `indents`.
crates/canvas/tests/clip_paste.rs+119
...@@ -191,3 +191,122 @@ fn a_title_takes_the_text_alone() {...@@ -191,3 +191,122 @@ fn a_title_takes_the_text_alone() {
191 .unwrap();191 .unwrap();
192 assert_eq!(title.shown_text(), "Bold title");192 assert_eq!(title.shown_text(), "Bold title");
193}193}
194
195/// Several lines pasted from the title go into the body as OneNote 2010 puts them (lab,
196/// 2026-10-02), plain or formatted, and the title keeps its text: at the end of the outline
197/// where the body starts, in a new one there on a page without body content, and otherwise
198/// in a new one two grid rows below the content.
199#[test]
200fn several_lines_pasted_from_the_title_go_into_the_body() {
201 let source = onestore::create_section("clips.one", "First page", "Author").unwrap();
202 let arena = Arena::default();
203 let mut section = Section::open(&arena, source.clone()).unwrap();
204 let mut at = 133_000_000_000_000_000;
205 let mut spaces = Vec::new();
206 for title in ["Title paste", "Far content"] {
207 let creation = onestore::PageCreation::new(None, Some(title), "Author").unwrap();
208 let create = Op::Section(onestore::op::SectionOp::Create(creation));
209 at += 10_000_000;
210 section
211 .apply(
212 "Author",
213 &Edit {
214 at,
215 ops: vec![create],
216 },
217 )
218 .unwrap();
219 spaces.push(section.pages().unwrap().last().unwrap().0);
220 }
221 let mut engine = TextEngine::default();
222 let mut store = |section: &mut Section, editor: &mut CanvasEditor, space| {
223 let ops = editor.take_ops().unwrap();
224 assert!(!ops.is_empty());
225 at += 10_000_000;
226 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
227 section.apply("Author", &Edit { at, ops }).unwrap();
228 };
229 let title = |editor: &mut CanvasEditor| {
230 let title = editor.outlines().iter().find(|o| o.title).unwrap().id;
231 editor.focus_outline(title).unwrap();
232 };
233 let origins = |editor: &CanvasEditor| {
234 editor
235 .outlines()
236 .iter()
237 .filter(|outline| !outline.title)
238 .map(|outline| outline.origin())
239 .collect::<Vec<_>>()
240 };
241
242 let page = section.page(spaces[0]).unwrap();
243 let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();
244 let start = editor.body_start().unwrap();
245 title(&mut editor);
246 editor
247 .paste(
248 &mut engine,
249 "First line\r\nSecond line\r\nThird line",
250 0x409,
251 )
252 .unwrap();
253 store(&mut section, &mut editor, spaces[0]);
254 title(&mut editor);
255 editor.paste(&mut engine, "Alpha\r\nBeta", 0x409).unwrap();
256 store(&mut section, &mut editor, spaces[0]);
257 assert_eq!(origins(&editor), [start]);
258
259 let page = section.page(spaces[1]).unwrap();
260 let mut far = CanvasEditor::from_page(page, &mut engine).unwrap();
261 let margin = far.margin_origin();
262 far.place_caret(&mut engine, [start[0] + 270.0, start[1] + 72.0], 468.0)
263 .unwrap();
264 far.insert(&mut engine, "far").unwrap();
265 let bottom = far.active_outline().bounds().y1 as f32;
266 title(&mut far);
267 let pieces = html_pieces("<p><b>One</b></p><ul><li>Two</li></ul>", 0x409, |_, _| None);
268 far.paste_pieces(&mut engine, pieces).unwrap();
269 store(&mut section, &mut far, spaces[1]);
270 let row = ((bottom - margin[1]) / 18.0).floor() + 2.0;
271 assert_eq!(
272 origins(&far),
273 [
274 [start[0] + 270.0, start[1] + 72.0],
275 [start[0], margin[1] + row * 18.0]
276 ]
277 );
278
279 let mut image = source;
280 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
281 let arena = Arena::default();
282 let reopened = Section::open(&arena, image.clone()).unwrap();
283 let page = reopened.page(spaces[0]).unwrap();
284 assert_eq!(page.title, "Title paste");
285 assert_eq!(
286 bodies(&page),
287 [vec![
288 "First line",
289 "Second line",
290 "Third line",
291 "Alpha",
292 "Beta"
293 ]]
294 );
295 assert_eq!(bodies(&page), bodies(&editor.page().unwrap()));
296 let page = reopened.page(spaces[1]).unwrap();
297 assert_eq!(page.title, "Far content");
298 assert_eq!(bodies(&page), [vec!["far"], vec!["One (bold)", "• Two"]]);
299
300 if let Some(directory) = std::env::var_os("CANVAS_TITLE_PASTE_EXPORT") {
301 let directory = std::path::PathBuf::from(directory);
302 std::fs::create_dir_all(&directory).unwrap();
303 std::fs::write(directory.join("clips.one"), &image).unwrap();
304 let file_id = Store::parse(&image).unwrap().header.file_id;
305 std::fs::write(
306 directory.join("Open Notebook.onetoc2"),
307 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("clips.one", file_id)])
308 .unwrap(),
309 )
310 .unwrap();
311 }
312}