| author | |
| committer | |
| log | 24d94846b5b33c2a832f3814bf0d80cf7a66cdde |
| tree | 6b3e24e404ac03c37f4c93d345e127f47f027be0 |
| parent | bc754925884fa44f7244701711aa28b4b1042437 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.53 files changed, 189 insertions(+), 47 deletions(-)
crates/canvas/src/editor.rs+43-28| ... | ... | @@ -2313,41 +2313,50 @@ impl CanvasEditor { |
| 2313 | 2313 | Ok(true) |
| 2314 | 2314 | } |
| 2315 | 2315 | |
| 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 | 2346 | /// Puts `image` at the caret as OneNote 2010 pastes and inserts a picture: in an outline |
| 2317 | 2347 | /// as [`Self::insert_attachment`] puts a file, and on blank page at the caret with the |
| 2318 | 2348 | /// 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 a | |
| 2320 | /// page without body content, and otherwise on the page two grid rows below the content | |
| 2321 | /// (lab, 2026-09-30). | |
| 2349 | /// title it goes where [`Self::leave_title_for_body`] puts the caret, in the flow there | |
| 2350 | /// unless on blank page. | |
| 2322 | 2351 | pub fn insert_picture( |
| 2323 | 2352 | &mut self, |
| 2324 | 2353 | engine: &mut TextEngine, |
| 2325 | 2354 | mut image: onestore::page::Image, |
| 2326 | 2355 | ) -> Result<(), EditorError> { |
| 2327 | let margin = self.margin_origin(); | |
| 2328 | if self.active_outline().title { | |
| 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 | )?; | |
| 2356 | if self.active_outline().title && self.leave_title_for_body(engine)? { | |
| 2357 | return self.insert_in_flow(engine, ParagraphContent::Image(image)); | |
| 2350 | 2358 | } |
| 2359 | let margin = self.margin_origin(); | |
| 2351 | 2360 | let Focus::Caret { outline, .. } = &self.active else { |
| 2352 | 2361 | return self.insert_in_flow(engine, ParagraphContent::Image(image)); |
| 2353 | 2362 | }; |
| ... | ... | @@ -3562,7 +3571,9 @@ impl CanvasEditor { |
| 3562 | 3571 | } |
| 3563 | 3572 | |
| 3564 | 3573 | /// 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 | 3577 | /// Pasted runs take the clipboard's `language`, an LCID, not the caret's run's as typing |
| 3567 | 3578 | /// does; Windows derives it from the keyboard language at copy time. |
| 3568 | 3579 | pub fn paste( |
| ... | ... | @@ -3582,6 +3593,9 @@ impl CanvasEditor { |
| 3582 | 3593 | .split('\n') |
| 3583 | 3594 | .map(|line| line.strip_suffix('\r').unwrap_or(line)) |
| 3584 | 3595 | .collect::<Vec<_>>(); |
| 3596 | if lines.len() > 1 && self.active_outline().title { | |
| 3597 | self.leave_title_for_body(engine)?; | |
| 3598 | } | |
| 3585 | 3599 | let last = lines[lines.len() - 1]; |
| 3586 | 3600 | let [anchor, focus] = self.active_outline().selection.positions; |
| 3587 | 3601 | let (start, end) = (anchor.min(focus), anchor.max(focus)); |
| ... | ... | @@ -3617,8 +3631,9 @@ impl CanvasEditor { |
| 3617 | 3631 | node.style = None; |
| 3618 | 3632 | node.lists.clear(); |
| 3619 | 3633 | } |
| 3634 | let head = clip::drop_empty_halves(&mut edit, lines.len(), true); | |
| 3620 | 3635 | let caret = TextPosition { |
| 3621 | paragraph: start.paragraph + lines.len(), | |
| 3636 | paragraph: start.paragraph + lines.len() - usize::from(head), | |
| 3622 | 3637 | offset: u32::try_from(last.encode_utf16().count()) |
| 3623 | 3638 | .map_err(|_| EditError::TextTooLong)?, |
| 3624 | 3639 | }; |
crates/canvas/src/editor/clip.rs+27-19| ... | ... | @@ -92,7 +92,7 @@ impl CanvasEditor { |
| 92 | 92 | /// Pastes `clip` as one undo step: one paragraph's runs go in at the selection; more |
| 93 | 93 | /// paragraphs go between the halves of the caret's paragraph, each keeping its |
| 94 | 94 | /// 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 | 96 | pub fn paste_clip(&mut self, engine: &mut TextEngine, clip: Clip) -> Result<(), EditorError> { |
| 97 | 97 | if self.page_selected() { |
| 98 | 98 | return self.grouped(|editor| { |
| ... | ... | @@ -100,7 +100,9 @@ impl CanvasEditor { |
| 100 | 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 | 106 | // A title takes text alone, as OneNote 2010's does (lab, 2026-10-02). |
| 105 | 107 | let language = leaves(&clip.paragraphs, None) |
| 106 | 108 | .find_map(|(.., node)| node.text()?.text.spans()[0].format.language) |
| ... | ... | @@ -153,23 +155,7 @@ impl CanvasEditor { |
| 153 | 155 | let count = nodes.len(); |
| 154 | 156 | let added = leaves(&nodes, None).count(); |
| 155 | 157 | edit.replacement.splice(1..=count, nodes); |
| 156 | // A half of the caret's paragraph goes where empty, as OneNote 2010 pastes (lab, | |
| 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 | } | |
| 158 | let head = drop_empty_halves(&mut edit, count, ends_in_text); | |
| 173 | 159 | let paragraph = start.paragraph + added - usize::from(head); |
| 174 | 160 | let caret = if ends_in_text { |
| 175 | 161 | TextPosition { |
| ... | ... | @@ -207,6 +193,28 @@ impl CanvasEditor { |
| 207 | 193 | } |
| 208 | 194 | } |
| 209 | 195 | |
| 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. | |
| 199 | pub(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 | 218 | /// Gives `nodes` and everything they hold new identities, keeping their tree, and points |
| 211 | 219 | /// their definitions at `renamed`'s, dropping what names none; a cell without indents takes |
| 212 | 220 | /// `indents`. |
crates/canvas/tests/clip_paste.rs+119| ... | ... | @@ -191,3 +191,122 @@ fn a_title_takes_the_text_alone() { |
| 191 | 191 | .unwrap(); |
| 192 | 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] | |
| 200 | fn 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 | } |