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
4use super::*;
5use 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)]
10pub struct Clip {
11 pub paragraphs: Vec<PageParagraph>,
12 /// The styles, lists and tags the paragraphs name.
13 pub definitions: BTreeMap<ExGuid, Definition>,
14}
15
16impl 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
58impl 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.
215pub(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`.
237fn 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)]
306impl 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)]
317fn 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)]
417mod 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}