| 1 | //! Deleting or typing over a selection that crosses a table's edge does what OneNote 2010 did |
| 2 | //! with the same keys (`tools/native_cross_container.py`, `corpus/cross-container`): nothing |
| 3 | //! joins across the edge, cells inside are emptied, and where the selection runs on past the |
| 4 | //! table its rows inside go, the table with them when all do. |
| 5 | |
| 6 | use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine}; |
| 7 | use onestore::{ |
| 8 | Arena, ExGuid, Section, Store, |
| 9 | op::{Edit, Op}, |
| 10 | page::{Page, PageObject, PageParagraph, ParagraphContent}, |
| 11 | }; |
| 12 | |
| 13 | const CROSS: &[u8] = include_bytes!("../../../corpus/cross-container/native/notebook/Cross.one"); |
| 14 | |
| 15 | /// The body's paragraphs in order: a text, or a table's rows of cell texts. |
| 16 | #[derive(Debug, PartialEq)] |
| 17 | enum Block { |
| 18 | Text(String), |
| 19 | Table(Vec<Vec<String>>), |
| 20 | } |
| 21 | |
| 22 | fn blocks(page: &Page) -> Vec<Block> { |
| 23 | let text = |node: &PageParagraph| node.text().unwrap().text.text().to_owned(); |
| 24 | page.objects |
| 25 | .iter() |
| 26 | .find_map(|object| match object { |
| 27 | PageObject::Outline(outline) => Some(&outline.paragraphs), |
| 28 | _ => None, |
| 29 | }) |
| 30 | .unwrap() |
| 31 | .iter() |
| 32 | .map(|node| match &node.content { |
| 33 | ParagraphContent::Table(table) => Block::Table( |
| 34 | table |
| 35 | .rows |
| 36 | .iter() |
| 37 | .map(|row| { |
| 38 | row.cells |
| 39 | .iter() |
| 40 | .map(|cell| cell.paragraphs.iter().map(text).collect()) |
| 41 | .collect() |
| 42 | }) |
| 43 | .collect(), |
| 44 | ), |
| 45 | _ => Block::Text(text(node)), |
| 46 | }) |
| 47 | .collect() |
| 48 | } |
| 49 | |
| 50 | fn at(paragraph: usize, offset: u32) -> TextPosition { |
| 51 | TextPosition { paragraph, offset } |
| 52 | } |
| 53 | |
| 54 | #[test] |
| 55 | fn deleting_across_a_tables_edge_keeps_each_side() { |
| 56 | let arena = Arena::default(); |
| 57 | let mut section = Section::open(&arena, CROSS.to_vec()).unwrap(); |
| 58 | let pages = section.pages().unwrap(); |
| 59 | let mut engine = TextEngine::default(); |
| 60 | let text = |value: &str| Block::Text(value.into()); |
| 61 | let table = |rows: &[[&str; 2]]| { |
| 62 | Block::Table( |
| 63 | rows.iter() |
| 64 | .map(|row| row.iter().map(|cell| cell.to_string()).collect()) |
| 65 | .collect(), |
| 66 | ) |
| 67 | }; |
| 68 | // Each page's selection, whether Delete is pressed, and what is typed then. |
| 69 | let cases = [ |
| 70 | ( |
| 71 | "Delete into a table", |
| 72 | [at(0, 6), at(1, 5)], |
| 73 | true, |
| 74 | "", |
| 75 | vec![ |
| 76 | text("Before"), |
| 77 | table(&[[" one", "Alpha two"], ["Beta one", "Beta two"]]), |
| 78 | text("After text"), |
| 79 | ], |
| 80 | ), |
| 81 | ( |
| 82 | "Delete out of a table", |
| 83 | [at(2, 5), at(5, 5)], |
| 84 | true, |
| 85 | "X", |
| 86 | vec![ |
| 87 | text("Before text"), |
| 88 | table(&[["Alpha one", "AlphaX"]]), |
| 89 | text(" text"), |
| 90 | ], |
| 91 | ), |
| 92 | ( |
| 93 | "Type over a row", |
| 94 | [at(0, 6), at(3, 4)], |
| 95 | false, |
| 96 | "Y", |
| 97 | vec![ |
| 98 | text("BeforeY"), |
| 99 | table(&[["", ""], [" one", "Beta two"]]), |
| 100 | text("After text"), |
| 101 | ], |
| 102 | ), |
| 103 | ( |
| 104 | "Delete a whole table", |
| 105 | [at(0, 6), at(5, 5)], |
| 106 | true, |
| 107 | "", |
| 108 | vec![text("Before"), text(" text")], |
| 109 | ), |
| 110 | ]; |
| 111 | let mut expected = Vec::new(); |
| 112 | for (index, (title, selection, delete, typed, blocks_after)) in cases.into_iter().enumerate() { |
| 113 | let space: ExGuid = pages.iter().find(|page| page.1 == title).unwrap().0; |
| 114 | let page = section.page(space).unwrap(); |
| 115 | let body = page |
| 116 | .objects |
| 117 | .iter() |
| 118 | .find_map(|object| match object { |
| 119 | PageObject::Outline(outline) => Some(outline.id), |
| 120 | _ => None, |
| 121 | }) |
| 122 | .unwrap(); |
| 123 | let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); |
| 124 | editor.focus_outline(body).unwrap(); |
| 125 | editor.select(selection.into()).unwrap(); |
| 126 | if delete { |
| 127 | editor.delete(&mut engine, false).unwrap(); |
| 128 | } |
| 129 | if !typed.is_empty() { |
| 130 | editor.insert(&mut engine, typed).unwrap(); |
| 131 | } |
| 132 | assert_eq!( |
| 133 | editor.selection().positions[1].paragraph, |
| 134 | selection[0].paragraph |
| 135 | ); |
| 136 | let ops = editor |
| 137 | .take_ops() |
| 138 | .unwrap() |
| 139 | .into_iter() |
| 140 | .map(|op| Op::Page { space, op }) |
| 141 | .collect(); |
| 142 | let at = 134_000_000_000_000_000 + index as u64 * 10_000_000; |
| 143 | section.apply("Author", &Edit { at, ops }).unwrap(); |
| 144 | assert_eq!( |
| 145 | blocks(&section.page(space).unwrap()), |
| 146 | blocks_after, |
| 147 | "{title}" |
| 148 | ); |
| 149 | expected.push((space, blocks_after)); |
| 150 | } |
| 151 | section.seal().unwrap(); |
| 152 | let written = section.image(); |
| 153 | let reread = Section::open(&arena, written.clone()).unwrap(); |
| 154 | for (space, blocks_after) in &expected { |
| 155 | assert_eq!(blocks(&reread.page(*space).unwrap()), *blocks_after); |
| 156 | } |
| 157 | if let Some(directory) = std::env::var_os("SNOWBOUND_CROSS_CONTAINER_EXPORT") { |
| 158 | let directory = std::path::PathBuf::from(directory); |
| 159 | std::fs::create_dir(&directory).unwrap(); |
| 160 | std::fs::write(directory.join("Cross.one"), &written).unwrap(); |
| 161 | let file_id = Store::parse(&written).unwrap().header.file_id; |
| 162 | std::fs::write( |
| 163 | directory.join("Open Notebook.onetoc2"), |
| 164 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("Cross.one", file_id)]) |
| 165 | .unwrap(), |
| 166 | ) |
| 167 | .unwrap(); |
| 168 | } |
| 169 | } |