| 1 | //! Copy and Paste of formatted content: a selection's paragraphs with the definitions they |
| 2 | //! name, which hosts put on the clipboard in Snowbound's own format beside HTML and text. |
| 3 | |
| 4 | use super::*; |
| 5 | use onestore::page::text::new_id; |
| 6 | |
| 7 | /// Paragraphs copied from a page, in document order with each parent before its children; |
| 8 | /// levels count from 1 and a paragraph whose parent was not copied has none. |
| 9 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 10 | pub struct Clip { |
| 11 | pub paragraphs: Vec<PageParagraph>, |
| 12 | /// The styles, lists and tags the paragraphs name. |
| 13 | pub definitions: BTreeMap<ExGuid, Definition>, |
| 14 | } |
| 15 | |
| 16 | impl Clip { |
| 17 | pub(crate) fn new( |
| 18 | mut paragraphs: Vec<PageParagraph>, |
| 19 | definitions: &BTreeMap<ExGuid, Definition>, |
| 20 | ) -> Self { |
| 21 | let held: BTreeSet<ExGuid> = paragraphs.iter().map(|node| node.id).collect(); |
| 22 | let shift = paragraphs.iter().map(|node| node.level).min().unwrap_or(1) - 1; |
| 23 | for node in &mut paragraphs { |
| 24 | node.parent = node.parent.filter(|parent| held.contains(parent)); |
| 25 | node.level -= shift; |
| 26 | } |
| 27 | Self { |
| 28 | definitions: ops::named(definitions, &[&paragraphs]), |
| 29 | paragraphs, |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | /// The text as shown, a line to each paragraph, table cells included. |
| 34 | pub fn text(&self) -> String { |
| 35 | leaves(&self.paragraphs, None) |
| 36 | .map(|(.., node)| { |
| 37 | let text = &node.text().unwrap().text; |
| 38 | text.project().map_or_else( |
| 39 | |_| text.text().to_owned(), |
| 40 | |shown| shown.text().text().to_owned(), |
| 41 | ) |
| 42 | }) |
| 43 | .collect::<Vec<_>>() |
| 44 | .join("\n") |
| 45 | } |
| 46 | |
| 47 | /// Snowbound's own clipboard format. |
| 48 | pub fn encode(&self) -> String { |
| 49 | serde_json::to_string(self).expect("a clip serializes") |
| 50 | } |
| 51 | |
| 52 | /// None for a clip another version of Snowbound wrote in a shape this one can't read. |
| 53 | pub fn decode(json: &str) -> Option<Self> { |
| 54 | serde_json::from_str(json).ok() |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | impl CanvasEditor { |
| 59 | pub(crate) fn clip_picture(&self, id: ExGuid) -> Result<Option<Clip>, EditError> { |
| 60 | let Some(image) = self.picture(id).filter(|image| image.bytes.is_some()) else { |
| 61 | return Ok(None); |
| 62 | }; |
| 63 | let mut node = crate::document::node( |
| 64 | Paragraph::new(String::new(), Default::default()), |
| 65 | Default::default(), |
| 66 | )?; |
| 67 | let mut image = image.clone(); |
| 68 | image.layout.x = None; |
| 69 | image.layout.y = None; |
| 70 | image.background = false; |
| 71 | node.content = ParagraphContent::Image(image); |
| 72 | Ok(Some(Clip::new(vec![node], &self.definitions))) |
| 73 | } |
| 74 | |
| 75 | /// What Copy takes: the selection's paragraphs with the first and last cut to it, or the |
| 76 | /// page selection's outlines top to bottom with an empty paragraph between, as OneNote |
| 77 | /// 2010 copies them. None where nothing shows. |
| 78 | pub fn clip(&self) -> Result<Option<Clip>, EditError> { |
| 79 | let paragraphs = if self.page_selected() { |
| 80 | let mut outlines = self |
| 81 | .outlines |
| 82 | .iter() |
| 83 | .filter(|outline| !outline.title) |
| 84 | .collect::<Vec<_>>(); |
| 85 | outlines.sort_by(|a, b| { |
| 86 | let ([ax, ay], [bx, by]) = (a.origin(), b.origin()); |
| 87 | ay.total_cmp(&by).then(ax.total_cmp(&bx)) |
| 88 | }); |
| 89 | let mut paragraphs = Vec::new(); |
| 90 | for outline in outlines { |
| 91 | if !paragraphs.is_empty() { |
| 92 | let empty = Paragraph::new(String::new(), Format::default()); |
| 93 | paragraphs.push(crate::document::node(empty, Format::default())?); |
| 94 | } |
| 95 | paragraphs.extend_from_slice(outline.document.nodes()); |
| 96 | } |
| 97 | paragraphs |
| 98 | } else { |
| 99 | let [anchor, focus] = self.selection().positions; |
| 100 | self.active_outline() |
| 101 | .document |
| 102 | .selected(anchor.min(focus)..anchor.max(focus))? |
| 103 | }; |
| 104 | let clip = Clip::new(paragraphs, &self.definitions); |
| 105 | Ok((!clip.text().is_empty()).then_some(clip)) |
| 106 | } |
| 107 | |
| 108 | /// Pastes `clip` as one undo step: one paragraph's runs go in at the selection; more |
| 109 | /// paragraphs go between the halves of the caret's paragraph, each keeping its |
| 110 | /// formatting, style, list, tags and indentation below that paragraph's. A title takes |
| 111 | /// one paragraph's text alone; more go into the body. |
| 112 | pub fn paste_clip(&mut self, engine: &mut TextEngine, clip: Clip) -> Result<(), EditorError> { |
| 113 | if clip.paragraphs.is_empty() { |
| 114 | return Ok(()); |
| 115 | } |
| 116 | crate::document::validate_nodes(&clip.paragraphs, &mut BTreeSet::new())?; |
| 117 | if self.page_selected() { |
| 118 | return self.grouped(|editor| { |
| 119 | editor.remove_page(engine, true)?; |
| 120 | editor.paste_clip(engine, clip) |
| 121 | }); |
| 122 | } |
| 123 | if self.active_outline().title && clip.paragraphs.len() > 1 { |
| 124 | self.leave_title_for_body(engine)?; |
| 125 | } else if self.active_outline().title { |
| 126 | // A title takes text alone, as OneNote 2010's does (lab, 2026-10-02). |
| 127 | let language = leaves(&clip.paragraphs, None) |
| 128 | .find_map(|(.., node)| node.text()?.text.spans()[0].format.language) |
| 129 | .unwrap_or(0x409); |
| 130 | return self.paste(engine, &clip.text(), language); |
| 131 | } |
| 132 | self.take_objects()?; |
| 133 | if let Some(pasted) = self.across(engine, |editor, engine| { |
| 134 | editor.paste_clip(engine, clip.clone()) |
| 135 | })? { |
| 136 | return Ok(pasted); |
| 137 | } |
| 138 | let mut nodes = self.admit(clip)?; |
| 139 | if nodes.is_empty() { |
| 140 | return Ok(()); |
| 141 | } |
| 142 | if let [node] = &nodes[..] |
| 143 | && let Some(text) = node.text() |
| 144 | { |
| 145 | return self.replace(engine, vec![text.text.clone()]); |
| 146 | } |
| 147 | let [anchor, focus] = self.active_outline().selection.positions; |
| 148 | let (start, end) = (anchor.min(focus), anchor.max(focus)); |
| 149 | let edge = Paragraph::new(String::new(), self.typing_format(start)?); |
| 150 | let texts = nodes.iter().map(|node| { |
| 151 | node.text() |
| 152 | .map_or_else(|| edge.clone(), |text| text.text.clone()) |
| 153 | }); |
| 154 | let replacement = std::iter::once(edge.clone()) |
| 155 | .chain(texts) |
| 156 | .chain([edge.clone()]) |
| 157 | .collect(); |
| 158 | let mut edit = self |
| 159 | .active_outline() |
| 160 | .document |
| 161 | .replace(start..end, replacement)?; |
| 162 | let head = &edit.replacement[0]; |
| 163 | let (parent, level) = (head.parent, head.level); |
| 164 | for node in &mut nodes { |
| 165 | if node.parent.is_none() { |
| 166 | node.parent = parent; |
| 167 | } |
| 168 | node.level = node |
| 169 | .level |
| 170 | .checked_add(level - 1) |
| 171 | .ok_or(EditError::InvalidStructure)?; |
| 172 | } |
| 173 | let ends_in_text = nodes.last().is_some_and(|node| node.text().is_some()); |
| 174 | let last = nodes |
| 175 | .last() |
| 176 | .and_then(|node| node.text()) |
| 177 | .map_or(Ok(0), |text| text.text.utf16_offset(text.text.text().len()))?; |
| 178 | let count = nodes.len(); |
| 179 | let added = leaves(&nodes, None).count(); |
| 180 | edit.replacement.splice(1..=count, nodes); |
| 181 | let head = drop_empty_halves(&mut edit, count, ends_in_text); |
| 182 | let paragraph = start.paragraph + added + usize::from(!ends_in_text) - usize::from(head); |
| 183 | let caret = TextPosition { |
| 184 | paragraph, |
| 185 | offset: last, |
| 186 | }; |
| 187 | self.commit(engine, edit, [caret; 2].into()) |
| 188 | } |
| 189 | |
| 190 | /// `clip`'s paragraphs under identities of their own, naming this page's definitions: |
| 191 | /// a style or tag the page already has, else one added beside its own. |
| 192 | fn admit(&mut self, clip: Clip) -> Result<Vec<PageParagraph>, EditError> { |
| 193 | let mut renamed = BTreeMap::new(); |
| 194 | for (id, definition) in clip.definitions { |
| 195 | let own = match definition.kind { |
| 196 | Kind::Style { .. } => self.define_style(&definition)?, |
| 197 | Kind::TagDefinition { .. } => self.define_tag(&definition)?, |
| 198 | _ => { |
| 199 | let own = new_id()?; |
| 200 | self.definitions.insert(own, definition); |
| 201 | own |
| 202 | } |
| 203 | }; |
| 204 | renamed.insert(id, own); |
| 205 | } |
| 206 | let mut nodes = clip.paragraphs; |
| 207 | renew(&mut nodes, &renamed, &self.active_outline().indents)?; |
| 208 | Ok(nodes) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | /// Drops a half of the caret's paragraph around `count` pasted nodes where empty, as OneNote |
| 213 | /// 2010 pastes (lab, 2026-10-02), unless it holds children or, past what `ends_in_text` |
| 214 | /// pasted, the caret; true if the upper half went. |
| 215 | pub(super) fn drop_empty_halves(edit: &mut DocumentEdit, count: usize, ends_in_text: bool) -> bool { |
| 216 | let gone = |edit: &DocumentEdit, at: usize| { |
| 217 | let node = &edit.replacement[at]; |
| 218 | node.text().is_some_and(|text| text.text.text().is_empty()) |
| 219 | && !edit |
| 220 | .replacement |
| 221 | .iter() |
| 222 | .any(|child| child.parent == Some(node.id)) |
| 223 | }; |
| 224 | if ends_in_text && gone(edit, count + 1) { |
| 225 | edit.replacement.remove(count + 1); |
| 226 | } |
| 227 | let head = gone(edit, 0); |
| 228 | if head { |
| 229 | edit.replacement.remove(0); |
| 230 | } |
| 231 | head |
| 232 | } |
| 233 | |
| 234 | /// Gives `nodes` and everything they hold new identities, keeping their tree, and points |
| 235 | /// their definitions at `renamed`'s, dropping what names none; a cell without indents takes |
| 236 | /// `indents`. |
| 237 | fn renew( |
| 238 | nodes: &mut Vec<PageParagraph>, |
| 239 | renamed: &BTreeMap<ExGuid, ExGuid>, |
| 240 | indents: &[f32], |
| 241 | ) -> Result<(), EditError> { |
| 242 | let tags = |tags: &mut Vec<onestore::document::Tag>| { |
| 243 | tags.retain_mut(|tag| match tag.definition { |
| 244 | Some(id) => renamed |
| 245 | .get(&id) |
| 246 | .map(|own| tag.definition = Some(*own)) |
| 247 | .is_some(), |
| 248 | None => true, |
| 249 | }); |
| 250 | }; |
| 251 | // What a clip from elsewhere holds without its bytes can't be pasted; their |
| 252 | // children lose their parent. |
| 253 | nodes.retain(|node| match &node.content { |
| 254 | ParagraphContent::Image(image) => image.bytes.is_some(), |
| 255 | ParagraphContent::Attachment(file) => file.bytes.is_some(), |
| 256 | ParagraphContent::Unsupported(_) => false, |
| 257 | _ => true, |
| 258 | }); |
| 259 | let mut parents = BTreeMap::new(); |
| 260 | for node in nodes.iter_mut() { |
| 261 | let id = new_id()?; |
| 262 | parents.insert(node.id, id); |
| 263 | node.id = id; |
| 264 | node.parent = node.parent.and_then(|parent| parents.get(&parent).copied()); |
| 265 | node.style = node.style.and_then(|style| renamed.get(&style).copied()); |
| 266 | node.lists = node |
| 267 | .lists |
| 268 | .iter() |
| 269 | .filter_map(|list| renamed.get(list).copied()) |
| 270 | .collect(); |
| 271 | tags(&mut node.tags); |
| 272 | node.media = Default::default(); |
| 273 | match &mut node.content { |
| 274 | ParagraphContent::Text(text) => { |
| 275 | text.id = new_id()?; |
| 276 | text.date_field = None; |
| 277 | tags(&mut text.tags); |
| 278 | } |
| 279 | ParagraphContent::Image(image) => { |
| 280 | image.id = new_id()?; |
| 281 | tags(&mut image.tags); |
| 282 | } |
| 283 | ParagraphContent::Attachment(file) => file.id = new_id()?, |
| 284 | ParagraphContent::Ink(ink) => ink.id = new_id()?, |
| 285 | ParagraphContent::Unsupported(unsupported) => unsupported.id = new_id()?, |
| 286 | ParagraphContent::Table(table) => { |
| 287 | table.id = new_id()?; |
| 288 | tags(&mut table.tags); |
| 289 | for row in &mut table.rows { |
| 290 | row.id = new_id()?; |
| 291 | for cell in &mut row.cells { |
| 292 | cell.id = new_id()?; |
| 293 | if cell.indents.is_empty() { |
| 294 | cell.indents = indents.to_vec(); |
| 295 | } |
| 296 | renew(&mut cell.paragraphs, renamed, indents)?; |
| 297 | } |
| 298 | } |
| 299 | } |
| 300 | } |
| 301 | } |
| 302 | Ok(()) |
| 303 | } |
| 304 | |
| 305 | #[cfg(test)] |
| 306 | impl Clip { |
| 307 | /// A line to each paragraph: its indentation, list, runs with their formatting, links, |
| 308 | /// tags and style, then each table's cells below it. |
| 309 | pub(super) fn outline(&self) -> Vec<String> { |
| 310 | let mut lines = Vec::new(); |
| 311 | outline(&self.paragraphs, &self.definitions, "", &mut lines); |
| 312 | lines |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | #[cfg(test)] |
| 317 | fn outline( |
| 318 | nodes: &[PageParagraph], |
| 319 | definitions: &BTreeMap<ExGuid, Definition>, |
| 320 | prefix: &str, |
| 321 | lines: &mut Vec<String>, |
| 322 | ) { |
| 323 | use super::format::AUTOMATIC; |
| 324 | for node in nodes { |
| 325 | let mut line = format!("{prefix}{}", " ".repeat(node.level as usize - 1)); |
| 326 | if let Some(kind @ Kind::List { .. }) = |
| 327 | node.lists.last().map(|list| &definitions[list].kind) |
| 328 | { |
| 329 | match super::html::list_tag(kind) { |
| 330 | ("ul", _) => line.push_str("• "), |
| 331 | (_, kind) => line.push_str(&format!("{kind}. ")), |
| 332 | } |
| 333 | } |
| 334 | let mut tags = node.tags.clone(); |
| 335 | match &node.content { |
| 336 | ParagraphContent::Text(text) => { |
| 337 | tags.extend(text.tags.iter().cloned()); |
| 338 | let shown = text.text.project().unwrap(); |
| 339 | let shown = shown.text(); |
| 340 | let mut byte = 0; |
| 341 | for span in shown.spans() { |
| 342 | let fragment = &shown.text()[byte..span.end]; |
| 343 | byte = span.end; |
| 344 | let format = &span.format; |
| 345 | let mut marks = Vec::new(); |
| 346 | for (set, mark) in [ |
| 347 | (format.bold, "b"), |
| 348 | (format.italic, "i"), |
| 349 | (format.underline, "u"), |
| 350 | (format.strike, "s"), |
| 351 | (format.superscript, "sup"), |
| 352 | (format.subscript, "sub"), |
| 353 | (format.hyperlink, "link"), |
| 354 | ] { |
| 355 | if set == Some(true) { |
| 356 | marks.push(mark.to_owned()); |
| 357 | } |
| 358 | } |
| 359 | if let Some(color) = format.color.filter(|color| *color != AUTOMATIC) { |
| 360 | marks.push(format!("color={color:06x}")); |
| 361 | } |
| 362 | if let Some(color) = format.highlight.filter(|color| *color != AUTOMATIC) { |
| 363 | marks.push(format!("highlight={color:06x}")); |
| 364 | } |
| 365 | if let Some(font) = format.font.as_deref().filter(|font| *font != "Calibri") { |
| 366 | marks.push(format!("font={font}")); |
| 367 | } |
| 368 | if let Some(size) = format.font_size.filter(|size| *size != 11.0) { |
| 369 | marks.push(format!("size={size}")); |
| 370 | } |
| 371 | if marks.is_empty() { |
| 372 | line.push_str(fragment); |
| 373 | } else { |
| 374 | line.push_str(&format!("<{}>{fragment}</>", marks.join(" "))); |
| 375 | } |
| 376 | } |
| 377 | for link in super::link::links(&text.text, 0) { |
| 378 | line.push_str(&format!(" -> {}", link.target)); |
| 379 | } |
| 380 | } |
| 381 | ParagraphContent::Table(table) => { |
| 382 | line.push_str(&format!( |
| 383 | "table {}x{}", |
| 384 | table.rows.len(), |
| 385 | table.columns.len() |
| 386 | )); |
| 387 | } |
| 388 | ParagraphContent::Image(image) => line.push_str(&format!("picture {:?}", image.size)), |
| 389 | _ => line.push_str("other"), |
| 390 | } |
| 391 | for tag in &tags { |
| 392 | if let Some(Kind::TagDefinition { label, .. }) = |
| 393 | tag.definition.map(|id| &definitions[&id].kind) |
| 394 | { |
| 395 | line.push_str(&format!(" [{}]", label.as_deref().unwrap_or(""))); |
| 396 | } |
| 397 | } |
| 398 | if let Some(Kind::Style { |
| 399 | name: Some(name), .. |
| 400 | }) = node.style.map(|id| &definitions[&id].kind) |
| 401 | { |
| 402 | line.push_str(&format!(" ({name})")); |
| 403 | } |
| 404 | lines.push(line); |
| 405 | if let ParagraphContent::Table(table) = &node.content { |
| 406 | for (row, cells) in table.rows.iter().enumerate() { |
| 407 | for (column, cell) in cells.cells.iter().enumerate() { |
| 408 | let prefix = format!("{prefix} | {row},{column}: "); |
| 409 | outline(&cell.paragraphs, definitions, &prefix, lines); |
| 410 | } |
| 411 | } |
| 412 | } |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | #[cfg(test)] |
| 417 | mod tests { |
| 418 | use super::*; |
| 419 | use crate::editor::format::{NoteTag, Toggle}; |
| 420 | use crate::editor::{Formatting, html_pieces}; |
| 421 | |
| 422 | #[test] |
| 423 | fn broken_clip_structure_is_rejected_before_editing() { |
| 424 | let mut engine = TextEngine::default(); |
| 425 | let mut editor = editor(&mut engine, &["original"]); |
| 426 | let original = editor.active_outline().document.clone(); |
| 427 | let mut clip = Clip::new(original.nodes().to_vec(), &BTreeMap::new()); |
| 428 | clip.paragraphs[0].level = 0; |
| 429 | assert!(editor.paste_clip(&mut engine, clip).is_err()); |
| 430 | assert_eq!(editor.active_outline().document, original); |
| 431 | } |
| 432 | |
| 433 | #[test] |
| 434 | fn pasted_levels_cannot_overflow_the_target_outline() { |
| 435 | let mut engine = TextEngine::default(); |
| 436 | let mut editor = editor(&mut engine, &["original", "second"]); |
| 437 | let mut nodes = editor.active_outline().document.nodes().to_vec(); |
| 438 | nodes[0].level = 2; |
| 439 | editor.active_outline_mut().document = TextDocument::from_nodes(nodes).unwrap(); |
| 440 | let original = editor.active_outline().document.clone(); |
| 441 | let mut clip = Clip::new(original.nodes().to_vec(), &BTreeMap::new()); |
| 442 | clip.paragraphs[0].level = u32::MAX; |
| 443 | assert!(editor.paste_clip(&mut engine, clip).is_err()); |
| 444 | assert_eq!(editor.active_outline().document, original); |
| 445 | } |
| 446 | |
| 447 | fn at(paragraph: usize, offset: u32) -> TextPosition { |
| 448 | TextPosition { paragraph, offset } |
| 449 | } |
| 450 | |
| 451 | fn editor(engine: &mut TextEngine, lines: &[&str]) -> CanvasEditor { |
| 452 | let paragraphs = lines |
| 453 | .iter() |
| 454 | .map(|line| { |
| 455 | Paragraph::new( |
| 456 | (*line).to_owned(), |
| 457 | Format { |
| 458 | font: Some("Calibri".into()), |
| 459 | font_size: Some(11.0), |
| 460 | ..Format::default() |
| 461 | }, |
| 462 | ) |
| 463 | }) |
| 464 | .collect(); |
| 465 | CanvasEditor::new(engine, TextDocument::new(paragraphs).unwrap(), 400.0).unwrap() |
| 466 | } |
| 467 | |
| 468 | fn page(editor: &mut CanvasEditor) -> Clip { |
| 469 | editor.select_page().unwrap(); |
| 470 | let clip = editor.clip().unwrap().unwrap(); |
| 471 | editor.select([at(0, 0); 2].into()).unwrap(); |
| 472 | clip |
| 473 | } |
| 474 | |
| 475 | /// The report's case: a bold line copied, through Snowbound's own format, into a new |
| 476 | /// paragraph stays bold. |
| 477 | #[test] |
| 478 | fn a_copied_bold_line_pastes_bold() { |
| 479 | let mut engine = TextEngine::default(); |
| 480 | let mut editor = editor(&mut engine, &["Bold line", ""]); |
| 481 | editor.select([at(0, 0), at(0, 9)].into()).unwrap(); |
| 482 | editor |
| 483 | .format(&mut engine, Formatting::Toggle(Toggle::Bold)) |
| 484 | .unwrap(); |
| 485 | let clip = editor.clip().unwrap().unwrap(); |
| 486 | assert_eq!(clip.text(), "Bold line"); |
| 487 | let clip = Clip::decode(&clip.encode()).unwrap(); |
| 488 | editor.select([at(1, 0); 2].into()).unwrap(); |
| 489 | editor.paste_clip(&mut engine, clip).unwrap(); |
| 490 | assert_eq!(editor.selection().positions, [at(1, 9); 2]); |
| 491 | assert_eq!( |
| 492 | page(&mut editor).outline(), |
| 493 | ["<b>Bold line</>", "<b>Bold line</>"] |
| 494 | ); |
| 495 | } |
| 496 | |
| 497 | /// Part of a paragraph copies only what is selected, and pastes into the middle of |
| 498 | /// another's text. |
| 499 | #[test] |
| 500 | fn a_partial_selection_pastes_inline() { |
| 501 | let mut engine = TextEngine::default(); |
| 502 | let mut editor = editor(&mut engine, &["one two three", "ab"]); |
| 503 | editor.select([at(0, 4), at(0, 7)].into()).unwrap(); |
| 504 | editor |
| 505 | .format(&mut engine, Formatting::Toggle(Toggle::Italic)) |
| 506 | .unwrap(); |
| 507 | let clip = editor.clip().unwrap().unwrap(); |
| 508 | editor.select([at(1, 1); 2].into()).unwrap(); |
| 509 | editor.paste_clip(&mut engine, clip).unwrap(); |
| 510 | assert_eq!( |
| 511 | page(&mut editor).outline(), |
| 512 | ["one <i>two</> three", "a<i>two</>b"] |
| 513 | ); |
| 514 | } |
| 515 | |
| 516 | /// Styles, lists, indentation, tags and tables go through Snowbound's own format to |
| 517 | /// another page between the halves of the caret's paragraph, as one undo step. |
| 518 | #[test] |
| 519 | fn a_clip_carries_styles_lists_tags_and_tables_to_another_page() { |
| 520 | let mut engine = TextEngine::default(); |
| 521 | let mut source = editor(&mut engine, &["Heading", "item one", "child", "tagged"]); |
| 522 | let heading = Definition { |
| 523 | kind: Kind::Style { |
| 524 | name: Some("h1".into()), |
| 525 | next: None, |
| 526 | }, |
| 527 | format: Format { |
| 528 | bold: Some(true), |
| 529 | font: Some("Calibri".into()), |
| 530 | font_size: Some(16.0), |
| 531 | color: Some(0x0060_3a1f), |
| 532 | ..Format::default() |
| 533 | }, |
| 534 | }; |
| 535 | let mut apply = |editor: &mut CanvasEditor, selection: [TextPosition; 2], command| { |
| 536 | editor.select(selection.into()).unwrap(); |
| 537 | editor.format(&mut engine, command).unwrap(); |
| 538 | }; |
| 539 | apply(&mut source, [at(0, 0); 2], Formatting::Style(heading)); |
| 540 | apply(&mut source, [at(1, 0), at(2, 5)], Formatting::Bullets); |
| 541 | apply(&mut source, [at(2, 2); 2], Formatting::Indent); |
| 542 | apply( |
| 543 | &mut source, |
| 544 | [at(3, 0); 2], |
| 545 | Formatting::Tag(NoteTag::defaults()[0].clone(), 0), |
| 546 | ); |
| 547 | source.select([at(3, 6); 2].into()).unwrap(); |
| 548 | source.insert_table(&mut engine, 2, 2).unwrap(); |
| 549 | source.insert(&mut engine, "a1").unwrap(); |
| 550 | let copied = page(&mut source); |
| 551 | let expected = [ |
| 552 | "<b color=603a1f size=16>Heading</> (h1)", |
| 553 | " • item one", |
| 554 | " • child", |
| 555 | "tagged [To Do]", |
| 556 | "table 2x2", |
| 557 | " | 0,0: a1", |
| 558 | " | 0,1: ", |
| 559 | " | 1,0: ", |
| 560 | " | 1,1: ", |
| 561 | ]; |
| 562 | assert_eq!(copied.outline(), expected); |
| 563 | |
| 564 | let mut target = editor(&mut engine, &["before after"]); |
| 565 | target.select([at(0, 7); 2].into()).unwrap(); |
| 566 | let clip = Clip::decode(&copied.encode()).unwrap(); |
| 567 | target.paste_clip(&mut engine, clip).unwrap(); |
| 568 | let pasted = page(&mut target).outline(); |
| 569 | assert_eq!(pasted[0], "before "); |
| 570 | assert_eq!(pasted[1..pasted.len() - 1], expected); |
| 571 | assert_eq!(pasted.last().unwrap(), "after"); |
| 572 | target.page().unwrap(); |
| 573 | assert!(target.undo(&mut engine).unwrap()); |
| 574 | assert_eq!(page(&mut target).outline(), ["before after"]); |
| 575 | } |
| 576 | |
| 577 | /// A page's HTML pasted into an empty paragraph takes its place, keeping its formatting |
| 578 | /// and lists, as OneNote 2010 pastes (lab, 2026-10-02). |
| 579 | #[test] |
| 580 | fn pasted_html_keeps_formatting_and_lists() { |
| 581 | let mut engine = TextEngine::default(); |
| 582 | let mut editor = editor(&mut engine, &[""]); |
| 583 | let pieces = html_pieces( |
| 584 | "<p><b>Bold</b> and <span style='color:#C00000'>red</span></p>\ |
| 585 | <ul><li>one<ul><li>two</li></ul></li></ul>", |
| 586 | 0x409, |
| 587 | |_, _| None, |
| 588 | ); |
| 589 | editor.paste_pieces(&mut engine, pieces).unwrap(); |
| 590 | assert_eq!( |
| 591 | page(&mut editor).outline(), |
| 592 | ["<b>Bold</> and <color=0000c0>red</>", "• one", " • two"] |
| 593 | ); |
| 594 | } |
| 595 | } |