From 05027137f9f30b0179a4f73df88c69aaf898d2de Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 25 Sep 2026 22:23:46 -0700 Subject: [PATCH] feat: structural edits everywhere, all nine tags, and pages that always open Enter, Backspace, Delete, Tab and Shift-Tab, lists and multi-line paste work in outlines with tags, lists, nesting and collapsed paragraphs, following OneNote 2010 keystroke by keystroke (evidence replayed in editor/evidence.rs): Enter never carries a tag, the upper paragraph wins a join, and bullets restyle as they indent. The toolbar applies all nine default tags, drawn from one set of tag art in canvas, and paste goes through the editor's paste. Pages our writer created open again (a missing indent table takes OneNote's default and either title flag marks the title), pictures that won't decode show a placeholder and save unchanged, and TIFFs decode. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5 --- Cargo.lock | 27 + .../assets/tags/address.svg | 0 .../assets/tags/contact.svg | 0 .../assets/tags/highlight.svg | 0 .../assets/tags/phone.svg | 0 .../assets/tags/star.svg | 0 crates/canvas/src/date.rs | 18 +- crates/canvas/src/document.rs | 338 ++++++-- crates/canvas/src/editor.rs | 451 ++++++++--- crates/canvas/src/editor/evidence.rs | 765 ++++++++++++++++++ crates/canvas/src/editor/format.rs | 181 ++++- crates/canvas/src/editor/page.rs | 20 +- crates/canvas/src/editor/table.rs | 31 +- crates/canvas/src/gpu/mod.rs | 17 +- crates/canvas/src/gpu/page.rs | 51 +- .../canvas/src/interaction/accessibility.rs | 5 + crates/canvas/src/interaction/mod.rs | 11 +- crates/canvas/src/outline.rs | 64 +- crates/canvas/tests/reopen.rs | 181 +++++ crates/draw/Cargo.toml | 2 +- crates/onestore/src/page/mod.rs | 17 +- crates/snowbound/src/art.rs | 5 - crates/snowbound/src/main.rs | 26 +- 23 files changed, 1979 insertions(+), 231 deletions(-) rename crates/{snowbound => canvas}/assets/tags/address.svg (100%) rename crates/{snowbound => canvas}/assets/tags/contact.svg (100%) rename crates/{snowbound => canvas}/assets/tags/highlight.svg (100%) rename crates/{snowbound => canvas}/assets/tags/phone.svg (100%) rename crates/{snowbound => canvas}/assets/tags/star.svg (100%) create mode 100644 crates/canvas/src/editor/evidence.rs create mode 100644 crates/canvas/tests/reopen.rs diff --git a/Cargo.lock b/Cargo.lock index 6bf20d2837fa08a080aa0d3d68222c77f9fa7e13..34a6fe55afe48023c7ef60de5d8340d15e9c73d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -792,6 +792,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1229,6 +1235,7 @@ dependencies = [ "moxcms", "num-traits", "png", + "tiff", "zune-core", "zune-jpeg", ] @@ -2282,6 +2289,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.41.0" @@ -2927,6 +2940,20 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "tiny-skia" version = "0.11.4" diff --git a/crates/snowbound/assets/tags/address.svg b/crates/canvas/assets/tags/address.svg similarity index 100% rename from crates/snowbound/assets/tags/address.svg rename to crates/canvas/assets/tags/address.svg diff --git a/crates/snowbound/assets/tags/contact.svg b/crates/canvas/assets/tags/contact.svg similarity index 100% rename from crates/snowbound/assets/tags/contact.svg rename to crates/canvas/assets/tags/contact.svg diff --git a/crates/snowbound/assets/tags/highlight.svg b/crates/canvas/assets/tags/highlight.svg similarity index 100% rename from crates/snowbound/assets/tags/highlight.svg rename to crates/canvas/assets/tags/highlight.svg diff --git a/crates/snowbound/assets/tags/phone.svg b/crates/canvas/assets/tags/phone.svg similarity index 100% rename from crates/snowbound/assets/tags/phone.svg rename to crates/canvas/assets/tags/phone.svg diff --git a/crates/snowbound/assets/tags/star.svg b/crates/canvas/assets/tags/star.svg similarity index 100% rename from crates/snowbound/assets/tags/star.svg rename to crates/canvas/assets/tags/star.svg diff --git a/crates/canvas/src/date.rs b/crates/canvas/src/date.rs index fc7cfab6c42caa1d69af24a9b70837e03db109ba..0ef98c2460670404ca1450e061750bd06efe0dd9 100644 --- a/crates/canvas/src/date.rs +++ b/crates/canvas/src/date.rs @@ -72,13 +72,17 @@ impl PageDate { if source.title || source.paragraphs.is_empty() || source.paragraphs.len() > 2 { return Err(EditError::UnsupportedContent.into()); } - let document = TextDocument::from_nodes(source.paragraphs.clone())?; - document.validate_flat()?; - if source - .paragraphs - .iter() - .any(|paragraph| paragraph.text().unwrap().text.spans().len() != 1) - { + TextDocument::from_nodes(source.paragraphs.clone())?; + // A date field is one plain run in a plain paragraph. + if source.paragraphs.iter().any(|paragraph| { + !paragraph.lists.is_empty() + || !paragraph.tags.is_empty() + || paragraph.collapsed + || paragraph.parent.is_some() + || paragraph + .text() + .is_none_or(|text| !text.tags.is_empty() || text.text.spans().len() != 1) + }) { return Err(EditError::UnsupportedContent.into()); } let layout = outline_layout(&source, engine, definitions, TITLE_WIDTH)?; diff --git a/crates/canvas/src/document.rs b/crates/canvas/src/document.rs index 08d18edf13876cd2278b99968f8c3bd5c9b86747..2cf577707378dae05471f0c125696899da26cd19 100644 --- a/crates/canvas/src/document.rs +++ b/crates/canvas/src/document.rs @@ -243,19 +243,158 @@ fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator }) } -pub(crate) fn validate_flat<'a>( - mut nodes: impl Iterator, -) -> Result<(), EditError> { - if nodes.any(|node| { - !node.lists.is_empty() - || !node.tags.is_empty() - || node.collapsed - || node.parent.is_some() - || node.text().is_none_or(|text| !text.tags.is_empty()) - }) { - return Err(EditError::UnsupportedContent); - } - Ok(()) +/// The index after `nodes[index]`'s last descendant. Descendants follow their ancestor +/// contiguously; a paragraph only an outline group indents has no parent to descend from. +pub(crate) fn subtree_end(nodes: &[PageParagraph], index: usize) -> usize { + let mut members = BTreeSet::from([nodes[index].id]); + index + + 1 + + nodes[index + 1..] + .iter() + .take_while(|node| { + node.parent.is_some_and(|parent| members.contains(&parent)) + && members.insert(node.id) + }) + .count() +} + +/// The nodes from `from` on that descend from a paragraph in `moves` or `shifts`, rebuilt so +/// the children of each paragraph in `moves` belong to its new parent, one level below it, and +/// every subtree keeps its depth below its root; ends at the last node that changes. +fn adopt( + nodes: &[PageParagraph], + from: usize, + moves: &BTreeMap, + mut shifts: BTreeMap, +) -> Result, EditError> { + let mut adopted = Vec::new(); + let mut changed = 0; + for node in &nodes[from..] { + let Some(parent) = node.parent else { break }; + let mut node = node.clone(); + let shift = match (moves.get(&parent), shifts.get(&parent)) { + (Some(holder), _) => { + node.parent = Some(holder.id); + i64::from(holder.level) + 1 - i64::from(node.level) + } + (None, Some(shift)) => *shift, + (None, None) => break, + }; + node.level = u32::try_from(i64::from(node.level) + shift) + .map_err(|_| EditError::InvalidStructure)?; + shifts.insert(node.id, shift); + if shift != 0 || moves.contains_key(&parent) { + changed = adopted.len() + 1; + } + adopted.push(node); + } + adopted.truncate(changed); + Ok(adopted) +} + +/// The sibling `nodes[index]` follows, passing over `skipped` siblings, when everything +/// between them descends from it or from a skipped paragraph. +pub(crate) fn previous_sibling( + nodes: &[PageParagraph], + index: usize, + skipped: &BTreeSet, +) -> Option { + let node = &nodes[index]; + let sibling = (0..index).rev().find(|&at| { + nodes[at].level <= node.level + && !(nodes[at].level == node.level && skipped.contains(&nodes[at].id)) + })?; + if nodes[sibling].level != node.level || nodes[sibling].parent != node.parent { + return None; + } + let mut members = BTreeSet::from([nodes[sibling].id]); + nodes[sibling + 1..index] + .iter() + .all(|node| { + let inside = skipped.contains(&node.id) + || node.parent.is_some_and(|parent| members.contains(&parent)); + members.insert(node.id); + inside + }) + .then_some(sibling) +} + +/// Tab or Shift+Tab on `range` of `nodes` as OneNote does, moving each paragraph with its +/// subtree: indenting makes a paragraph the last child of its previous sibling, or without one +/// indents it within its group; outdenting a child makes it its parent's sibling, adopting the +/// siblings after it. None when nothing moves. +pub(crate) fn indent( + nodes: &[PageParagraph], + container: Option, + range: Range, + outdent: bool, +) -> Option { + let selected = nodes[range.clone()] + .iter() + .map(|node| node.id) + .collect::>(); + let mut end = range.end; + let mut tops = BTreeMap::new(); + let mut adopters = BTreeMap::new(); + for index in range.clone() { + let node = &nodes[index]; + if node.parent.is_some_and(|parent| selected.contains(&parent)) { + continue; + } + end = end.max(subtree_end(nodes, index)); + if !outdent { + let parent = previous_sibling(nodes, index, &selected).map(|at| nodes[at].id); + tops.insert(node.id, (parent.or(node.parent), 1)); + } else if node.level > 1 { + let parent = node + .parent + .and_then(|id| nodes[..index].iter().rposition(|node| node.id == id)) + .filter(|&at| nodes[at].level + 1 == node.level); + let parent = match parent { + Some(at) => { + end = end.max(subtree_end(nodes, at)); + adopters.insert(nodes[at].id, node.id); + nodes[at].parent + } + None => node.parent, + }; + tops.insert(node.id, (parent, -1)); + } + } + if tops.is_empty() { + return None; + } + let mut shifts = BTreeMap::new(); + let replacement = nodes[range.start..end] + .iter() + .map(|node| { + let mut node = node.clone(); + let shift = match (tops.get(&node.id), node.parent) { + (Some(&(parent, shift)), _) => { + node.parent = parent; + shift + } + (None, Some(parent)) + if !selected.contains(&node.id) && adopters.contains_key(&parent) => + { + node.parent = Some(adopters[&parent]); + 0 + } + (None, parent) => parent + .and_then(|parent| shifts.get(&parent).copied()) + .unwrap_or(0), + }; + node.level = node.level.saturating_add_signed(shift); + shifts.insert(node.id, shift); + node + }) + .collect(); + Some(DocumentEdit { + columns: BTreeMap::new(), + container, + range: range.start..end, + replacement, + }) } pub(crate) fn container_mut( @@ -318,10 +457,6 @@ impl TextDocument { &self.nodes } - pub(crate) fn validate_flat(&self) -> Result<(), EditError> { - validate_flat(self.nodes.iter()) - } - pub fn text_nodes(&self) -> impl Iterator { leaves(&self.nodes, None).map(|(_, _, node)| node) } @@ -452,6 +587,11 @@ impl TextDocument { Ok(result) } + /// Replaces `range` as OneNote's typing, Enter and deletion do: the first paragraph keeps + /// its identity and properties, and a paragraph the replacement adds takes its level, + /// parent, style and lists but no note tags, except that a range's last paragraph stays + /// itself when the replacement ends in one. The last paragraph holds the children of the + /// paragraphs the edit removes or splits. pub(crate) fn replace( &self, range: Range, @@ -469,13 +609,7 @@ impl TextDocument { if container != end_container { return Err(EditError::UnsupportedContent); } - if range.start.paragraph != range.end.paragraph || replacement.len() != 1 { - validate_flat(self.container(container)?.iter().enumerate().filter_map( - |(index, node)| { - ((start..=end).contains(&index) || node.text().is_some()).then_some(node) - }, - ))?; - } + let nodes = self.container(container)?; let mut prefix = first.text().unwrap().text.slice(0..range.start.offset)?; let last_text = &last.text().unwrap().text; let suffix = @@ -484,32 +618,90 @@ impl TextDocument { prefix.append(replacement.next().ok_or(EditError::InvalidRange)?)?; let mut head = first.clone(); head.text_mut().unwrap().text = prefix; - let mut nodes = vec![head]; let following = replacement.len(); + let keeps_last = start != end && following > 0; + let mut added = Vec::new(); for (index, text) in replacement.enumerate() { - if index + 1 == following && range.start.paragraph != range.end.paragraph { + let mut next = if keeps_last && index + 1 == following { let mut end = last.clone(); end.text_mut().unwrap().text = text; - nodes.push(end); + end } else { let mut next = node(text, first.format.clone())?; - next.level = first.level; next.style = first.style; - nodes.push(next); - } + next.lists.clone_from(&first.lists); + next + }; + next.parent = first.parent; + next.level = first.level; + added.push(next); } + if let Some(tail) = added.last_mut() { + tail.collapsed |= std::mem::take(&mut head.collapsed); + } + let tail = added.last_mut().unwrap_or(&mut head); if !suffix.text().is_empty() { - let end = nodes.last_mut().unwrap(); - end.text_mut().unwrap().text.append(suffix)?; + tail.text_mut().unwrap().text.append(suffix)?; } + let tail = added.last().unwrap_or(&head); + let mut moves = nodes[start + 1..end + usize::from(!keeps_last)] + .iter() + .map(|node| (node.id, tail)) + .collect::>(); + if tail.id != head.id { + moves.insert(head.id, tail); + } + let shifts = keeps_last + .then(|| (last.id, i64::from(first.level) - i64::from(last.level))) + .into_iter() + .collect(); + let adopted = adopt(nodes, end + 1, &moves, shifts)?; Ok(DocumentEdit { columns: BTreeMap::new(), container, - range: start..end + 1, - replacement: nodes, + range: start..end + 1 + adopted.len(), + replacement: [head].into_iter().chain(added).chain(adopted).collect(), }) } + /// Appends text leaf `lower`'s text to `upper`'s, keeping the upper paragraph's properties + /// and giving it the lower one's children; None unless nothing but `upper`'s hidden subtree + /// lies between them in one container. + pub(crate) fn join( + &self, + upper: usize, + lower: usize, + ) -> Result, EditError> { + let (container, first, top) = self.leaf(upper).ok_or(EditError::InvalidRange)?; + let (end_container, last, bottom) = self.leaf(lower).ok_or(EditError::InvalidRange)?; + let nodes = self.container(container)?; + if container != end_container + || last <= first + || last > first + 1 && !(top.collapsed && subtree_end(nodes, first) == last) + { + return Ok(None); + } + let mut head = top.clone(); + let text = head.text_mut().unwrap(); + // OneNote moves the lower text object into an emptied upper paragraph. + if text.text.text().is_empty() { + text.id = bottom.text().unwrap().id; + } + text.text.append(bottom.text().unwrap().text.clone())?; + let moves = BTreeMap::from([(bottom.id, &head)]); + let adopted = adopt(nodes, last + 1, &moves, BTreeMap::new())?; + Ok(Some(DocumentEdit { + columns: BTreeMap::new(), + container, + range: first..last + 1 + adopted.len(), + replacement: [head] + .into_iter() + .chain(nodes[first + 1..last].iter().cloned()) + .chain(adopted) + .collect(), + })) + } + /// Rejects exactly the edits after which [`validate_nodes`] would reject the document, or /// whose column widths or text are invalid, looking only where the edit can conflict. pub(crate) fn validate_edit(&self, edit: &DocumentEdit) -> Result<(), EditError> { @@ -899,13 +1091,21 @@ mod tests { assert_eq!(document.nodes()[1], original.nodes()[1]); document.apply(undo).unwrap(); assert_eq!(document, original); - assert_eq!( - document.replace( + let edit = document + .replace( position(0, 0)..position(4, 0), - vec![Paragraph::new(String::new(), Format::default())] - ), - Err(EditError::UnsupportedContent) + vec![Paragraph::new(String::new(), Format::default())], + ) + .unwrap(); + document.apply(edit).unwrap(); + assert_eq!( + document + .paragraphs() + .map(Paragraph::text) + .collect::>(), + ["after"] ); + assert_eq!(document.nodes().len(), 1); } #[test] @@ -1221,7 +1421,7 @@ mod tests { } #[test] - fn editing_nested_tagged_text_preserves_metadata_and_refuses_structural_changes() { + fn editing_nested_tagged_text_preserves_metadata_through_structural_changes() { let mut nodes = TextDocument::new( ["parent", "a🌳e\u{301}z", "child"] .into_iter() @@ -1284,19 +1484,51 @@ mod tests { assert_eq!(document, edited); } } - for (range, count) in [ - (position(1, 0)..position(1, 0), 2), - (position(0, 6)..position(1, 0), 1), - (position(1, 0)..position(2, 0), 1), - ] { - assert_eq!( - original.replace( + let structure = |range: Range, count| { + let mut document = original.clone(); + let edit = document + .replace( range, - vec![Paragraph::new(String::new(), Format::default()); count] - ), - Err(EditError::UnsupportedContent) - ); - } + vec![Paragraph::new(String::new(), Format::default()); count], + ) + .unwrap(); + let undo = document.apply(edit).unwrap(); + let edited = document.nodes().to_vec(); + document.apply(undo).unwrap(); + assert_eq!(document, original); + edited + }; + let [parent, item, child] = original.nodes() else { + unreachable!() + }; + // The new half takes the list, the children and their collapsed state, not the tags. + let split = structure(position(1, 0)..position(1, 0), 2); + assert_eq!(split[1].id, item.id); + assert!(split[1].text().unwrap().text.text().is_empty() && !split[1].collapsed); + assert_eq!(split[1].tags, item.tags); + let tail = &split[2]; + assert_eq!(tail.text().unwrap().text, item.text().unwrap().text); + assert_eq!((tail.parent, tail.level), (Some(parent.id), 2)); + assert_eq!(tail.lists, item.lists); + assert!(tail.tags.is_empty() && tail.text().unwrap().tags.is_empty() && tail.collapsed); + assert_eq!((split[3].parent, split[3].level), (Some(tail.id), 3)); + // The upper paragraph wins a join and adopts the lower one's children. + let joined = structure(position(0, 6)..position(1, 0), 1); + assert_eq!(joined[0].text().unwrap().text.text(), "parenta🌳e\u{301}z"); + assert_eq!((joined[0].id, joined[0].lists.len()), (parent.id, 0)); + assert_eq!( + (joined[1].id, joined[1].parent, joined[1].level), + (child.id, Some(parent.id), 2) + ); + let joined = structure(position(1, 6)..position(2, 0), 1); + assert_eq!(joined.len(), 2); + assert_eq!( + PageParagraph { + content: item.content.clone(), + ..joined[1].clone() + }, + *item + ); } #[test] @@ -1365,7 +1597,7 @@ mod tests { .unwrap(); for (original, regions) in [ (original, vec![0, 0, 0]), - (table_document(), vec![0, 1, 1, 2, 3]), + (table_document(), vec![0, 1, 1, 2, 0]), ] { let positions: Vec<_> = original .paragraphs() diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 44cda7d0629a12db8d922d723fab1f205b8167a2..ca9616bb9be4fe7186b3d9f2d976d59522511451 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -6,6 +6,7 @@ use crate::{ }; use draw::edit::{self, Movement, SelectionUnit}; use onestore::ExGuid; +use onestore::document::{Format, Kind}; use onestore::page::text::{EditError, Paragraph}; use onestore::page::{Definition, Outline, Page, PageObject, PageParagraph, Title}; use parley::{ @@ -20,6 +21,8 @@ use std::{ pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0; +#[cfg(test)] +mod evidence; mod format; pub(crate) mod page; mod table; @@ -126,22 +129,20 @@ pub struct TextOutline { impl TextOutline { /// A single plain paragraph containing only ASCII spaces or no text. pub fn is_empty(&self) -> bool { - self.document.nodes().len() == 1 - && self.document.validate_flat().is_ok() - && self - .document - .paragraphs() - .next() - .unwrap() - .text() - .bytes() - .all(|byte| byte == b' ') + let [node] = self.document.nodes() else { + return false; + }; + node.lists.is_empty() + && node.tags.is_empty() + && !node.collapsed + && node.text().is_some_and(|text| { + text.tags.is_empty() && text.text.text().bytes().all(|byte| byte == b' ') + }) } /// Tests an outline-local point against the provisional paragraph band. pub fn contains_extension(&self, point: [f32; 2]) -> bool { !self.title - && self.document.validate_flat().is_ok() && (0.0..=self.shaped.size[0]).contains(&point[0]) && point[1] > self.shaped.size[1] && point[1] <= self.shaped.size[1] + 27.0 @@ -1148,7 +1149,11 @@ impl CanvasEditor { let outline = self.active_outline(); let count = outline.document.nodes().len(); let height = outline.shaped.size[1]; - let node = self.blank_paragraph(&outline.document.nodes()[count - 1])?; + let last = &outline.document.nodes()[count - 1]; + let node = PageParagraph { + parent: last.parent, + ..self.blank_paragraph(last)? + }; self.apply( engine, DocumentEdit { @@ -1864,13 +1869,6 @@ impl CanvasEditor { }; if target.is_none() && !extend && anchor == focus && !self.active_outline().title { let outline = self.active_outline(); - crate::document::validate_flat( - outline - .document - .nodes() - .iter() - .filter(|node| node.text().is_some()), - )?; let last = outline.document.nodes().len() - 1; let up = matches!(movement, Movement::Up); let base_node = &outline.document.nodes()[if up { 0 } else { last }]; @@ -1964,7 +1962,10 @@ impl CanvasEditor { columns: BTreeMap::new(), container: None, range: last + 1..last + 1, - replacement: vec![node], + replacement: vec![PageParagraph { + parent: base_node.parent, + ..node + }], }; let paragraph = outline.document.paragraphs().count(); let previous = if let Focus::Outline(index) = self.active { @@ -2223,12 +2224,127 @@ impl CanvasEditor { self.replace(engine, replacement) } + /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style + /// or list between the halves of the caret's paragraph (`evidence/structural-edits/xml/c7-*`). + pub fn paste(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { + let lines = text + .split('\n') + .map(|line| line.strip_suffix('\r').unwrap_or(line)) + .collect::>(); + let last = lines[lines.len() - 1]; + if lines.len() == 1 { + return self.insert(engine, last); + } + let [anchor, focus] = self.active_outline().selection.positions; + let (start, end) = (anchor.min(focus), anchor.max(focus)); + let edge = Paragraph::new(String::new(), self.typing_format(start)?); + let pasted = Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + ..Format::default() + }; + let mut edit = self.active_outline().document.replace( + start..end, + std::iter::once(edge.clone()) + .chain( + lines + .iter() + .map(|line| Paragraph::new((*line).to_owned(), pasted.clone())), + ) + .chain([edge]) + .collect(), + )?; + for node in &mut edit.replacement[1..=lines.len()] { + node.style = None; + node.lists.clear(); + } + let caret = TextPosition { + paragraph: start.paragraph + lines.len(), + offset: u32::try_from(last.encode_utf16().count()) + .map_err(|_| EditError::TextTooLong)?, + }; + self.commit( + engine, + edit, + Selection { + positions: [caret; 2], + affinities: [Affinity::Upstream; 2], + }, + ) + } + + /// Enter as OneNote 2010 does (`evidence/structural-edits`): the new paragraph takes the + /// level, lists and character formatting at the caret but no note tags. At a paragraph's + /// start its tags stay with its text below; an empty paragraph opens a plain one above and + /// leaves the list it ends; a heading continues as body text. + fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> { + let [anchor, focus] = self.active_outline().selection.positions; + let (start, end) = (anchor.min(focus), anchor.max(focus)); + let format = self.typing_format(start)?; + let document = &self.active_outline().document; + let mut edit = + document.replace(start..end, vec![Paragraph::new(String::new(), format); 2])?; + let nodes = document.container(edit.container)?; + let next = nodes + .get(crate::document::subtree_end(nodes, edit.range.start)) + .filter(|next| { + let node = &nodes[edit.range.start]; + next.parent == node.parent && next.level == node.level + }); + let [head, tail, ..] = &mut edit.replacement[..] else { + unreachable!("a split holds both halves") + }; + let empty = |node: &PageParagraph| node.text().unwrap().text.text().is_empty(); + if start.offset == 0 { + tail.tags = std::mem::take(&mut head.tags); + tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags); + } + let style = |id: Option| match &self.definitions.get(&id?)?.kind { + Kind::Style { name } => name.as_deref(), + _ => None, + }; + if empty(head) && empty(tail) { + head.lists.clear(); + if next.is_none_or(|next| next.lists.is_empty()) { + tail.lists.clear(); + } + } else if empty(tail) + && matches!( + style(head.style), + Some("h1" | "h2" | "h3" | "h4" | "h5" | "h6") + ) + { + tail.style = self + .definitions + .keys() + .copied() + .find(|id| style(Some(*id)) == Some("p")); + tail.text_mut().unwrap().text = + Paragraph::new(String::new(), self.style_format(tail.style)?); + } + let caret = TextPosition { + paragraph: start.paragraph + 1, + offset: 0, + }; + self.commit( + engine, + edit, + Selection { + positions: [caret; 2], + affinities: [Affinity::Upstream; 2], + }, + ) + } + + /// Tab or Shift+Tab at the start of the selected paragraphs; see [`crate::document::indent`]. + /// A default bullet or number steps to the style OneNote gives its new depth. pub fn indent(&mut self, engine: &mut TextEngine, outdent: bool) -> Result { let outline = self.active_outline(); if outline.title { return Ok(false); } - let [anchor, focus] = outline.selection.positions; + let selection = outline.selection; + let [anchor, focus] = selection.positions; let start = anchor.min(focus); let end = anchor.max(focus); let (container, local_start, _) = outline @@ -2245,96 +2361,111 @@ impl CanvasEditor { let range = local_start ..local_end + usize::from(end.offset != 0 || start.paragraph == end.paragraph); let nodes = outline.document.container(container)?; - // Lists and tags move with their paragraphs; parent links and collapsed children would not. - if nodes.iter().enumerate().any(|(index, node)| { - (range.contains(&index) || node.text().is_some()) - && (node.parent.is_some() || node.collapsed) - }) { - return Err(EditError::UnsupportedContent.into()); - } - let nodes = &nodes[range.clone()]; - if outdent && nodes.iter().all(|node| node.level == 1) { + let Some(mut edit) = crate::document::indent(nodes, container, range, outdent) else { return Ok(false); - } - let replacement = nodes + }; + let levels = nodes[edit.range.clone()] .iter() - .map(|node| { - let mut node = node.clone(); - node.level = if outdent { - node.level.saturating_sub(1).max(1) - } else { - node.level - .checked_add(1) - .ok_or(EditError::InvalidStructure)? + .map(|node| node.level) + .collect::>(); + for (node, level) in edit.replacement.iter_mut().zip(levels) { + if node.level == level { + continue; + } + for list in &mut node.lists { + let Some(definition) = self + .definitions + .get(list) + .and_then(|definition| format::nested_list(definition, node.level > level)) + else { + continue; }; - Ok(node) - }) - .collect::, EditError>>()?; - self.commit( - engine, - DocumentEdit { - columns: BTreeMap::new(), - container, - range, - replacement, - }, - outline.selection, - )?; + *list = onestore::page::text::new_id()?; + self.definitions.insert(*list, definition); + } + } + self.commit(engine, edit, selection)?; Ok(true) } + /// Deletes the selection or the character beside the caret. Backspace at a paragraph's start + /// first removes its list, then outdents it, then joins it to the paragraph above, whose + /// properties win; Delete at its end joins the paragraph below (`evidence/structural-edits/ + /// xml/c4-*`, `c5b-*`). Joins pass over a collapsed paragraph's hidden children. pub fn delete(&mut self, engine: &mut TextEngine, backward: bool) -> Result { - let [anchor, focus] = self.active_outline().selection.positions; + let selection = self.active_outline().selection; + let [anchor, focus] = selection.positions; let mut range = anchor.min(focus)..anchor.max(focus); if range.is_empty() { - let paragraph = self.active_outline().paragraph_layout(focus.paragraph)?; - let cursor = - paragraph.cursor(focus.offset, self.active_outline().selection.affinities[1])?; - if let Some(cluster) = + let outline = self.active_outline(); + let paragraph = outline.paragraph_layout(focus.paragraph)?; + let cursor = paragraph.cursor(focus.offset, selection.affinities[1])?; + let Some(cluster) = cursor.logical_clusters(¶graph.text.shaped)[usize::from(!backward)] - { - let visible = paragraph.projection.text(); - let bytes = cluster.text_range(); - range.start.offset = paragraph.projection.source_offset( - visible.utf16_offset(bytes.start)?, - onestore::page::text::Affinity::Downstream, - )?; - range.end.offset = paragraph.projection.source_offset( - visible.utf16_offset(bytes.end)?, - onestore::page::text::Affinity::Upstream, - )?; - } else { - let document = &self.active_outline().document; - let Some(neighbor) = (if backward { - focus.paragraph.checked_sub(1) - } else { - focus.paragraph.checked_add(1) - }) else { - return Ok(false); - }; - let (container, local, current) = document + else { + let (container, local, node) = outline + .document .leaf(focus.paragraph) .ok_or(EditError::InvalidRange)?; - let Some((next_container, next_local, next)) = document.leaf(neighbor) else { + if backward && !node.lists.is_empty() { + let item = PageParagraph { + lists: Vec::new(), + ..node.clone() + }; + let edit = DocumentEdit { + columns: BTreeMap::new(), + container, + range: local..local + 1, + replacement: vec![item], + }; + self.commit(engine, edit, selection)?; + return Ok(true); + } + if backward && node.level > 1 { + return self.indent(engine, true); + } + let visible = outline.visible_index(focus.paragraph)?; + let Some(neighbor) = (if backward { + visible.checked_sub(1) + } else { + Some(visible + 1).filter(|next| *next < outline.shaped.paragraphs.len()) + }) else { return Ok(false); }; - if container != next_container || local.abs_diff(next_local) != 1 { - return Ok(false); - } - let (previous, start, end) = if backward { - (next, neighbor, focus.paragraph) + let neighbor = outline.source_index(neighbor); + let [upper, lower] = if backward { + [neighbor, focus.paragraph] } else { - (current, focus.paragraph, neighbor) + [focus.paragraph, neighbor] + }; + let Some(edit) = outline.document.join(upper, lower)? else { + return Ok(false); }; - let text = &previous.text().unwrap().text; - range = TextPosition { - paragraph: start, + let text = outline.document.paragraph(upper).unwrap(); + let caret = TextPosition { + paragraph: upper, offset: text.utf16_offset(text.text().len())?, - }..TextPosition { - paragraph: end, - offset: 0, }; - } + self.commit( + engine, + edit, + Selection { + positions: [caret; 2], + affinities: [Affinity::Upstream; 2], + }, + )?; + return Ok(true); + }; + let visible = paragraph.projection.text(); + let bytes = cluster.text_range(); + range.start.offset = paragraph.projection.source_offset( + visible.utf16_offset(bytes.start)?, + onestore::page::text::Affinity::Downstream, + )?; + range.end.offset = paragraph.projection.source_offset( + visible.utf16_offset(bytes.end)?, + onestore::page::text::Affinity::Upstream, + )?; } self.delete_range(engine, range) } @@ -2748,10 +2879,11 @@ impl CanvasEditor { .split('\n') .map(|part| Paragraph::new(part.to_owned(), format.clone())) .collect(); - let edit = self + let mut edit = self .active_outline() .document .replace(range.clone(), replacement)?; + self.own_lists(&mut edit)?; let inverse = self.apply( engine, edit, @@ -2764,6 +2896,12 @@ impl CanvasEditor { )?; let original = if let Some(composition) = self.composition.take() { let mut original = composition.original; + // Nodes this edit reaches past what the composition changed are still as they were. + let changed = original.edit.range.len(); + original + .edit + .replacement + .extend(inverse.edit.replacement.iter().skip(changed).cloned()); original.edit.range = inverse.edit.range; for (id, widths) in inverse.edit.columns { original.edit.columns.entry(id).or_insert(widths); @@ -2832,14 +2970,40 @@ impl CanvasEditor { fn commit( &mut self, engine: &mut TextEngine, - edit: DocumentEdit, + mut edit: DocumentEdit, selection: Selection, ) -> Result<(), EditorError> { + self.own_lists(&mut edit)?; let inverse = self.apply(engine, edit, selection, true, None)?; self.record_change(inverse); Ok(()) } + /// Gives each paragraph an edit adds its own copy of the lists it carries, as OneNote keeps + /// a list node per paragraph. + fn own_lists(&mut self, edit: &mut DocumentEdit) -> Result<(), EditError> { + let existing = self.active_outline().document.container(edit.container)? + [edit.range.clone()] + .iter() + .map(|node| node.id) + .collect::>(); + for node in &mut edit.replacement { + if existing.contains(&node.id) { + continue; + } + for list in &mut node.lists { + let definition = self + .definitions + .get(list) + .ok_or(EditError::InvalidStructure)? + .clone(); + *list = onestore::page::text::new_id()?; + self.definitions.insert(*list, definition); + } + } + Ok(()) + } + fn record_change(&mut self, change: TextChange) { self.pending = None; if let Focus::Draft { index, .. } = self.active { @@ -5553,6 +5717,9 @@ mod tests { .collect::>(), [2, 2, 1] ); + // Backspace outdents an indented paragraph before joining it (`c4-child-1.xml`). + editor.delete(&mut engine, true).unwrap(); + assert_eq!(editor.active_outline().document.nodes()[1].level, 1); editor.delete(&mut engine, true).unwrap(); assert_eq!( editor.active_outline().document.nodes()[0] @@ -5562,9 +5729,9 @@ mod tests { .text(), "alpha beta gamma delta" ); - editor.undo(&mut engine).unwrap(); - editor.undo(&mut engine).unwrap(); - editor.undo(&mut engine).unwrap(); + for _ in 0..4 { + editor.undo(&mut engine).unwrap(); + } assert_eq!(editor.active_outline().document, source); assert_eq!(editor.selection(), selection); assert!(!editor.indent(&mut engine, true).unwrap()); @@ -6505,7 +6672,16 @@ mod tests { let edited = layout_snapshot(&editor.active_outline().shaped); let before = editor.active_outline().document.clone(); let selection = editor.selection(); - assert!(editor.insert(&mut engine, "\n").is_err()); + editor.enter(&mut engine, false).unwrap(); + let split = &editor.active_outline().document.nodes()[2]; + assert_eq!((split.parent, split.level), (Some(nodes[0].id), 2)); + assert_ne!(split.lists, [marker]); + assert_eq!( + editor.definitions[&split.lists[0]], + editor.definitions[&marker] + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, before); assert!(editor.resize(&mut engine, 20.0).is_err()); assert_eq!(editor.active_outline().document, before); assert_eq!(editor.selection(), selection); @@ -6555,6 +6731,75 @@ mod tests { assert!(!editor.selection_rects().unwrap().is_empty()); } + #[test] + fn joins_pass_over_hidden_children_and_a_split_hands_them_on_still_collapsed() { + let mut engine = TextEngine::default(); + let mut nodes = TextDocument::new( + ["Collapsed", "Hidden", "After"] + .into_iter() + .map(|text| Paragraph::new(text.into(), Format::default())) + .collect(), + ) + .unwrap() + .nodes() + .to_vec(); + nodes[0].collapsed = true; + nodes[1].parent = Some(nodes[0].id); + nodes[1].level = 2; + let outline = Outline { + paragraphs: nodes.clone(), + ..TextOutline::new( + &mut engine, + TextDocument::from_nodes(nodes.clone()).unwrap(), + 300.0, + [0.0; 2], + ) + .unwrap() + .snapshot() + }; + let mut editor = + CanvasEditor::from_outlines(&mut engine, vec![outline], BTreeMap::new()).unwrap(); + let texts = |editor: &CanvasEditor| { + editor + .active_outline() + .document + .paragraphs() + .map(|text| text.text().to_owned()) + .collect::>() + }; + let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into(); + editor.select(at(2, 0)).unwrap(); + assert!(editor.delete(&mut engine, true).unwrap()); + assert_eq!(texts(&editor), ["CollapsedAfter", "Hidden"]); + assert_eq!( + editor.selection().positions, + [TextPosition { + paragraph: 0, + offset: 9 + }; 2] + ); + editor.undo(&mut engine).unwrap(); + editor.select(at(0, 9)).unwrap(); + assert!(editor.delete(&mut engine, false).unwrap()); + assert_eq!(texts(&editor), ["CollapsedAfter", "Hidden"]); + editor.undo(&mut engine).unwrap(); + editor.select(at(0, 9)).unwrap(); + editor.enter(&mut engine, false).unwrap(); + let split = editor.active_outline().document.nodes(); + assert!(!split[0].collapsed && split[1].collapsed); + assert_eq!(split[2].parent, Some(split[1].id)); + assert_eq!( + editor + .active_outline() + .layouts() + .map(|(index, _)| index) + .collect::>(), + [0, 1, 3] + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document.nodes(), nodes); + } + #[test] #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"] fn imported_editor_reflows_and_restores_native_outline_geometry() { @@ -7934,9 +8179,13 @@ mod tests { .outlines .iter() .find(|outline| { + let [node] = outline.document.nodes() else { + return false; + }; !outline.title - && outline.document.nodes().len() == 1 - && outline.document.validate_flat().is_ok() + && node.lists.is_empty() + && node.tags.is_empty() + && node.text().is_some_and(|text| text.tags.is_empty()) }) .unwrap() .id; diff --git a/crates/canvas/src/editor/evidence.rs b/crates/canvas/src/editor/evidence.rs new file mode 100644 index 0000000000000000000000000000000000000000..e257b26eeef0450fd5f2b0e518cb3e94f7b2c96b --- /dev/null +++ b/crates/canvas/src/editor/evidence.rs @@ -0,0 +1,765 @@ +//! OneNote 2010's Enter, Backspace, Delete, Tab and paste replayed against the page XML it +//! reported after each keystroke (`evidence/structural-edits`): each outline is built from, and +//! compared with, the one-line-per-paragraph summaries in `summaries/`, whose `L` counts +//! paragraph nesting. + +use super::format::{NoteTag, list_definition}; +use super::*; +use onestore::document::Tag; +use onestore::page::ParagraphContent; +use onestore::page::text::new_id; + +fn summary(name: &str) -> String { + std::fs::read_to_string(format!( + "{}/../../evidence/structural-edits/summaries/{name}.txt", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() + .replace("\r\n", "\n") +} + +fn calibri(size: f32, bold: bool) -> Format { + Format { + font: Some("Calibri".into()), + font_size: Some(size), + bold: Some(bold), + ..Format::default() + } +} + +/// The quick styles a summary's `qs0` and `qs1` name. +const STYLES: [&str; 2] = ["p", "h1"]; + +/// An editor holding one outline shaped as a summary describes it. +fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor { + let mut definitions = BTreeMap::new(); + let mut ids = BTreeMap::new(); + let mut define = |key: String, definition: Definition| { + *ids.entry(key).or_insert_with(|| { + let id = new_id().unwrap(); + definitions.insert(id, definition); + id + }) + }; + let styles = STYLES.map(|name| { + define( + name.into(), + Definition { + kind: Kind::Style { + name: Some(name.into()), + }, + format: calibri(if name == "p" { 11.0 } else { 16.0 }, name != "p"), + }, + ) + }); + let mut nodes: Vec = Vec::new(); + for line in summary.lines() { + let line = line.trim_start().strip_prefix('L').unwrap(); + let (level, line) = line.split_once(" [").unwrap(); + let level = level.parse::().unwrap(); + let (tags, line) = line.split_once("] [").unwrap(); + let (list, line) = line.split_once("] ").unwrap(); + let (style, text) = line.split_once(' ').unwrap(); + let style = STYLES + .iter() + .position(|name| style == format!("qs{}", usize::from(*name == "h1"))) + .map(|index| styles[index]); + let format = calibri(11.0, false); + let mut node = + crate::document::node(Paragraph::new(text.into(), format.clone()), format.clone()) + .unwrap(); + node.level = level; + node.style = style; + node.parent = nodes + .iter() + .rev() + .find(|parent| parent.level + 1 == level) + .map(|parent| parent.id); + if !list.is_empty() { + let definition = list_definition(list.starts_with("num"), &format); + node.lists = vec![define(format!("list {}", nodes.len()), definition)]; + } + for tag in tags.split(',').filter(|tag| !tag.is_empty()) { + let (label, completed) = match tag.strip_suffix("[x]") { + Some(label) => (label, true), + None => (tag, false), + }; + let tag = NoteTag::ALL + .into_iter() + .find(|tag| { + matches!(&tag.definition().kind, Kind::TagDefinition { label: Some(name), .. } if name == label) + }) + .unwrap(); + let definition = define(label.into(), tag.definition()); + node.text_mut().unwrap().tags.push(Tag { + definition: Some(definition), + action_type: None, + status: u16::from(completed), + created: Some(1), + completed: Some(u32::from(completed)), + start: None, + due: None, + task_id: None, + extra_set: 0, + }); + } + nodes.push(node); + } + let outline = Outline { + id: new_id().unwrap(), + title: false, + min_width: None, + layout: onestore::document::Layout { + max_width: Some(400.0), + ..Default::default() + }, + indents: vec![18.0, 0.0, 27.0, 27.0], + paragraphs: nodes, + unsupported: Vec::new(), + }; + CanvasEditor::from_outlines(engine, vec![outline], definitions).unwrap() +} + +/// The outline in the summaries' form. +fn render(editor: &CanvasEditor) -> String { + let outline = editor.active_outline(); + let mut depths = BTreeMap::new(); + let mut lines = Vec::new(); + for node in outline.document.nodes() { + let depth = node.parent.map_or(1, |parent| depths[&parent] + 1); + depths.insert(node.id, depth); + let tags = node + .tags + .iter() + .chain(&node.text().unwrap().tags) + .map(|tag| { + let kind = &editor.definitions[&tag.definition.unwrap()].kind; + let Kind::TagDefinition { label, .. } = kind else { + panic!("{kind:?}") + }; + let checked = if tag.status & 1 == 1 { "[x]" } else { "" }; + ( + NoteTag::ALL + .iter() + .position(|known| known.definition().kind == *kind), + format!("{}{checked}", label.as_deref().unwrap()), + ) + }) + .collect::>(); + let list = match node.lists.first().map(|id| &editor.definitions[id].kind) { + None => String::new(), + Some(Kind::List { + bullet: Some(_), .. + }) => "bullet".into(), + Some(Kind::List { + format: Some(format), + .. + }) => { + let (_, rest) = format.split_once('\u{fffd}').unwrap(); + let mut rest = rest.chars(); + let number = outline + .shaped + .paragraphs + .iter() + .find(|paragraph| paragraph.id == node.id) + .and_then(|paragraph| paragraph.number) + .unwrap() + .0; + let numeral = crate::outline::numeral(rest.next(), number).unwrap(); + format!("num:{numeral}{}", rest.as_str()) + } + Some(kind) => panic!("{kind:?}"), + }; + let style = node + .style + .map(|style| match &editor.definitions[&style].kind { + Kind::Style { name } => { + format!("qs{}", usize::from(name.as_deref() == Some("h1"))) + } + kind => panic!("{kind:?}"), + }) + .unwrap_or_default(); + lines.push(format!( + "{}L{depth} [{}] [{list}] {style} {}", + " ".repeat(depth as usize), + tags.into_values().collect::>().join(","), + node.text().unwrap().text.text() + )); + } + lines.join("\n").trim_end().to_owned() +} + +#[derive(Clone, Copy)] +enum Offset { + Start, + End, + /// Characters before the end. + Back(u32), + /// Characters after the start. + Forward(u32), +} + +#[derive(Clone, Copy)] +enum Key { + Enter, + Backspace, + Delete, + Tab, + ShiftTab, + Type(&'static str), + Paste(&'static str), + Bullets, +} + +/// Puts the caret at `offset` in paragraph `paragraph`. +fn place(editor: &mut CanvasEditor, (paragraph, offset): (usize, Offset)) { + let text = editor + .active_outline() + .document + .paragraph(paragraph) + .unwrap(); + let length = text.text().encode_utf16().count() as u32; + let offset = match offset { + Offset::Start => 0, + Offset::End => length, + Offset::Back(count) => length - count, + Offset::Forward(count) => count, + }; + editor + .select([TextPosition { paragraph, offset }; 2].into()) + .unwrap(); +} + +fn press(editor: &mut CanvasEditor, engine: &mut TextEngine, key: Key) { + match key { + Key::Enter => editor.enter(engine, false).unwrap(), + Key::Backspace => assert!(editor.delete(engine, true).unwrap()), + Key::Delete => assert!(editor.delete(engine, false).unwrap()), + Key::Tab => editor.tab(engine, false).unwrap(), + Key::ShiftTab => editor.tab(engine, true).unwrap(), + Key::Type(text) => editor.insert(engine, text).unwrap(), + Key::Paste(text) => editor.paste(engine, text).unwrap(), + Key::Bullets => editor.format(engine, Formatting::Bullets).unwrap(), + } +} + +/// Replays `steps` from `caret` in `before`, comparing the outline after each key with the +/// summary it names; returns the editor for further checks. +fn replay( + engine: &mut TextEngine, + before: &str, + caret: (usize, Offset), + steps: &[(Key, &str)], +) -> CanvasEditor { + let mut editor = open(engine, before); + place(&mut editor, caret); + let original = editor.active_outline().document.clone(); + for (key, after) in steps { + press(&mut editor, engine, *key); + assert_eq!(render(&editor), summary(after).trim_end(), "{after}"); + let outline = editor.active_outline(); + let fresh = OutlineLayout::flow( + outline.document.nodes().iter(), + &outline.indents, + outline.wrap_width(), + true, + 0, + None, + &mut |node, previous, width, indents| { + ParagraphLayout::shape(engine, node, previous, width, indents, &editor.definitions) + }, + ) + .unwrap(); + let numbers = |layout: &OutlineLayout| { + layout + .paragraphs + .iter() + .map(|paragraph| paragraph.number) + .collect::>() + }; + assert_eq!(numbers(&outline.shaped), numbers(&fresh), "{after}"); + } + let edited = editor.active_outline().document.clone(); + while editor.undo(engine).unwrap() {} + assert_eq!(editor.active_outline().document, original); + while editor.redo(engine).unwrap() {} + assert_eq!(editor.active_outline().document, edited); + editor +} + +/// The run that recorded Enter at the end of a paragraph of `kind`. +fn first_run(kind: &str) -> &'static str { + match kind { + "num" | "child" | "tagbullet" => "c1b", + _ => "c1", + } +} + +/// The setup every single-paragraph case starts from: `Above`, then `Target text` of `kind`. +fn target(kind: &str) -> String { + summary(&format!("{}-{kind}-0", first_run(kind))) +} + +const KINDS: [&str; 9] = [ + "todo", + "done", + "imp", + "q", + "rem", + "bullet", + "num", + "child", + "tagbullet", +]; + +#[test] +fn enter_carries_level_lists_and_children_but_never_tags() { + let mut engine = TextEngine::default(); + for kind in KINDS { + let prefix = first_run(kind); + replay( + &mut engine, + &target(kind), + (1, Offset::End), + &[ + (Key::Enter, &format!("{prefix}-{kind}-1")), + (Key::Type("New"), &format!("{prefix}-{kind}-2")), + ], + ); + replay( + &mut engine, + &target(kind), + (1, Offset::Back(4)), + &[ + (Key::Enter, &format!("c2-{kind}-1")), + (Key::Type("X"), &format!("c2-{kind}-2")), + ], + ); + replay( + &mut engine, + &target(kind), + (1, Offset::Start), + &[ + (Key::Enter, &format!("c2s-{kind}-1")), + (Key::Type("X"), &format!("c2s-{kind}-2")), + ], + ); + replay( + &mut engine, + &summary(&format!("c3-{kind}-0")), + (2, Offset::Start), + &[ + (Key::Enter, &format!("c3-{kind}-1")), + (Key::Enter, &format!("c3-{kind}-2")), + (Key::Type("New"), &format!("c3-{kind}-3")), + ], + ); + } + replay( + &mut engine, + &summary("pbe-0"), + (2, Offset::Start), + &[(Key::Enter, "pbe-1")], + ); + replay( + &mut engine, + &summary("c8-enter-empty-0"), + (1, Offset::End), + &[ + (Key::Enter, "c8-enter-empty-1"), + (Key::Enter, "c8-enter-empty-2"), + ], + ); + replay( + &mut engine, + &summary("c8-split-0"), + (2, Offset::Forward(1)), + &[(Key::Enter, "c8-split-1")], + ); + replay( + &mut engine, + &summary("c10-enter-end-0"), + (1, Offset::End), + &[(Key::Enter, "c10-enter-end-1")], + ); +} + +#[test] +fn backspace_removes_the_list_then_outdents_then_joins_and_the_upper_paragraph_wins() { + let mut engine = TextEngine::default(); + for kind in KINDS { + let steps = ["-1", "-2", "-3"].map(|step| format!("c4-{kind}{step}")); + replay( + &mut engine, + &summary(&format!("c4-{kind}-0")), + (2, Offset::Start), + &steps.each_ref().map(|step| (Key::Backspace, step.as_str())), + ); + } + for pair in [ + "imp-todo", + "plain-todo", + "todo-plain", + "todo-done", + "done-todo", + "q-rem", + ] { + replay( + &mut engine, + &summary(&format!("c4m-{pair}-0")), + (1, Offset::Start), + &[(Key::Backspace, &format!("c4m-{pair}-1"))], + ); + } + replay( + &mut engine, + &summary("c4m-emptybullet-0"), + (2, Offset::Start), + &[ + (Key::Backspace, "c4m-emptybullet-1"), + (Key::Backspace, "c4m-emptybullet-2"), + (Key::Backspace, "c4m-emptybullet-3"), + ], + ); + replay( + &mut engine, + &summary("c4m-emptytag-0"), + (1, Offset::Start), + &[ + (Key::Backspace, "c4m-emptytag-1"), + (Key::Backspace, "c4m-emptytag-2"), + ], + ); + replay( + &mut engine, + &summary("c8-bs-0"), + (2, Offset::Start), + &[(Key::Backspace, "c8-bs-1"), (Key::Backspace, "c8-bs-2")], + ); + replay( + &mut engine, + &summary("c10-bs-parent-0"), + (1, Offset::Start), + &[(Key::Backspace, "c10-bs-parent-1")], + ); +} + +#[test] +fn delete_at_the_end_joins_the_next_paragraph_into_this_one() { + let mut engine = TextEngine::default(); + for (pair, paragraph) in [ + ("imp-todo", 0), + ("plain-todo", 0), + ("todo-plain", 0), + ("done-todo", 0), + ("plain-bullet", 0), + ("plain-num", 0), + ("bullet-plain", 1), + ("bullet-bullet", 1), + ] { + replay( + &mut engine, + &summary(&format!("c5b-{pair}-0")), + (paragraph, Offset::End), + &[(Key::Delete, &format!("c5b-{pair}-1"))], + ); + } + replay( + &mut engine, + &summary("c8-del-0"), + (1, Offset::End), + &[(Key::Delete, "c8-del-1")], + ); + for (name, paragraph) in [("del-alpha", 0), ("del-parent", 1)] { + replay( + &mut engine, + &summary(&format!("c10-{name}-0")), + (paragraph, Offset::End), + &[(Key::Delete, &format!("c10-{name}-1"))], + ); + } +} + +/// The list definition of the outline's paragraph `index`. +fn list(editor: &CanvasEditor, index: usize) -> &Kind<'static> { + let node = &editor.active_outline().document.nodes()[index]; + &editor.definitions[&node.lists[0]].kind +} + +#[test] +fn tab_nests_under_the_previous_sibling_and_shift_tab_adopts_the_siblings_after() { + let mut engine = TextEngine::default(); + for kind in ["plain", "todo", "bullet", "num", "child", "tagbullet"] { + let editor = replay( + &mut engine, + &summary(&format!("c6-{kind}-0")), + (1, Offset::Start), + &[(Key::Tab, &format!("c6-{kind}-tab"))], + ); + // Without a previous sibling OneNote indents within the group (`` + // in `c6-{bullet,num,child,tagbullet}-tab.xml`), stepping bullets to â—‹ and numbers to a. + let first_child = matches!(kind, "bullet" | "num" | "child" | "tagbullet"); + let node = &editor.active_outline().document.nodes()[1]; + assert_eq!(node.level, if first_child { 3 } else { 2 }, "{kind}"); + if matches!(kind, "bullet" | "tagbullet") { + assert!(matches!( + list(&editor, 1), + Kind::List { format: Some(glyph), bullet: Some(4), .. } if glyph == "\u{25cb}" + )); + } + replay( + &mut engine, + &summary(&format!("c6-{kind}-0")), + (1, Offset::Start), + &[ + (Key::Tab, &format!("c6-{kind}-tab")), + (Key::ShiftTab, &format!("c6-{kind}-stab1")), + (Key::ShiftTab, &format!("c6-{kind}-stab2")), + ], + ); + } + let first = replay( + &mut engine, + " L1 [] [] qs0 Target text", + (0, Offset::Start), + &[(Key::Tab, "c6-first-tab")], + ); + assert_eq!( + ( + first.active_outline().document.nodes()[0].level, + first.active_outline().document.nodes()[0].parent + ), + (2, None) + ); + replay( + &mut engine, + &summary("c8-stab-0"), + (2, Offset::Start), + &[(Key::ShiftTab, "c8-stab-1")], + ); + replay( + &mut engine, + &summary("c8-tab-0"), + (2, Offset::Start), + &[(Key::Tab, "c8-tab-1"), (Key::ShiftTab, "c8-tab-2")], + ); + replay( + &mut engine, + &summary("c10-stab-child-0"), + (2, Offset::Start), + &[(Key::ShiftTab, "c10-stab-child-1")], + ); +} + +#[test] +fn pasted_lines_are_plain_paragraphs_between_the_halves() { + let mut engine = TextEngine::default(); + for kind in ["todo", "done", "bullet", "num", "tagbullet", "child"] { + // The native run's clipboard held a stray backquote for the numbered case. + let text = if kind == "num" { + "Line one\r\nLine two`" + } else { + "Line one\r\nLine two" + }; + let editor = replay( + &mut engine, + &target(kind), + (1, Offset::Back(4)), + &[(Key::Paste(text), &format!("c7-{kind}-1"))], + ); + let document = &editor.active_outline().document; + let format = Format { + bold: None, + ..calibri(11.0, false) + }; + assert_eq!(document.paragraph(2).unwrap().spans()[0].format, format); + let last = document.paragraph(3).unwrap().text(); + assert_eq!( + editor.selection().positions[1], + TextPosition { + paragraph: 3, + offset: last.len() as u32 + } + ); + } + replay( + &mut engine, + " L1 [] [] qs0 Above\n L2 [] [num:1.] qs0 First\n L2 [] [num:2.] qs0 Second", + (1, Offset::Back(1)), + &[(Key::Paste("Line one\r\nLine two"), "c7-num2-1")], + ); +} + +#[test] +fn a_list_applied_after_a_plain_paragraph_nests_under_it_and_stays_nested() { + let mut engine = TextEngine::default(); + replay( + &mut engine, + &summary("pb-0"), + (1, Offset::Start), + &[(Key::Bullets, "pb-1"), (Key::Bullets, "pb-2")], + ); +} + +#[test] +fn a_heading_continues_as_body_text_unless_split() { + let mut engine = TextEngine::default(); + // `c9-h1-1.xml` and `c9-h1-2.xml`; their summaries also carry the heading's inline runs. + let mut editor = open(&mut engine, " L1 [] [] qs1 Heading"); + let end = TextPosition { + paragraph: 0, + offset: 7, + }; + editor.select([end; 2].into()).unwrap(); + editor.enter(&mut engine, false).unwrap(); + editor.insert(&mut engine, "body").unwrap(); + assert_eq!( + render(&editor), + " L1 [] [] qs1 Heading\n L1 [] [] qs0 body" + ); + let body = editor.active_outline().document.paragraph(1).unwrap(); + assert_eq!(body.spans()[0].format, calibri(11.0, false)); + let middle = TextPosition { + paragraph: 0, + offset: 4, + }; + editor.select([middle; 2].into()).unwrap(); + editor.enter(&mut engine, false).unwrap(); + assert_eq!( + render(&editor), + " L1 [] [] qs1 Head\n L1 [] [] qs1 ing\n L1 [] [] qs0 body" + ); +} + +#[test] +fn a_split_keeps_every_tag_above_and_a_join_takes_the_upper_tags() { + let mut engine = TextEngine::default(); + // `c9-multitag-{1,2,3}.xml`. + let mut editor = open( + &mut engine, + " L1 [] [] qs0 Alpha\n L1 [To Do,Important[x]] [] qs0 Two tags", + ); + let at = |offset| TextPosition { + paragraph: 1, + offset, + }; + editor.select([at(4); 2].into()).unwrap(); + editor.enter(&mut engine, false).unwrap(); + assert_eq!(render(&editor), summary("c9-multitag-1").trim_end()); + editor.delete(&mut engine, true).unwrap(); + assert_eq!(render(&editor), summary("c9-multitag-2").trim_end()); + editor.select([at(0); 2].into()).unwrap(); + editor.delete(&mut engine, true).unwrap(); + assert_eq!(render(&editor), summary("c9-multitag-3").trim_end()); +} + +/// Nesting, adopted siblings, restyled and removed lists, an outdent, a tagged split and a join +/// that hands on children, on pages OneNote wrote, read back from the page writer unchanged. +#[test] +fn structural_edits_on_native_pages_survive_the_page_writer() { + use onestore::{RevisionIndex, Store, document::Document}; + let section = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../evidence/structural-edits/probe-section/probe.one" + )) + .unwrap(); + let page = |bytes: &[u8], title: &str| { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (space, _) = document + .pages() + .unwrap() + .into_iter() + .find(|(space, _)| Page::from_space(&document, *space).unwrap().title == title) + .unwrap(); + (space, Page::from_space(&document, space).unwrap()) + }; + // Tags keep their place in the element's property arena only once read. + let settled = |mut page: Page| { + for object in &mut page.objects { + if let PageObject::Outline(outline) = object { + for paragraph in &mut outline.paragraphs { + for tag in &mut paragraph.text_mut().unwrap().tags { + tag.extra_set = 0; + } + } + } + } + page + }; + let mut engine = TextEngine::default(); + for (title, keys) in [ + ( + "c8-tab", + &[ + ((2, Offset::Start), Key::ShiftTab), + ((4, Offset::Start), Key::Tab), + ((3, Offset::Start), Key::Backspace), + ][..], + ), + ( + "c4-tagbullet", + &[ + ((1, Offset::Start), Key::Backspace), + ((1, Offset::Start), Key::Backspace), + ], + ), + ("c2-todo", &[((1, Offset::Forward(3)), Key::Enter)]), + ( + "c10-stab-child", + &[ + ((2, Offset::Start), Key::Tab), + ((3, Offset::Start), Key::Tab), + ((1, Offset::End), Key::Delete), + ], + ), + ] { + let (space, source) = page(§ion, title); + let mut editor = CanvasEditor::from_page(source, &mut engine).unwrap(); + let body = editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + editor.focus_outline(body).unwrap(); + for (caret, key) in keys { + place(&mut editor, *caret); + press(&mut editor, &mut engine, *key); + } + let edited = settled(editor.page().unwrap()); + let written = onestore::PreparedEdit::page(§ion, space, &edited, "Author") + .unwrap_or_else(|error| panic!("{title}: {error}")); + let (_, reread) = page(written.as_bytes(), title); + assert_eq!(settled(reread).objects, edited.objects, "{title}"); + } +} + +#[test] +fn tab_inside_text_makes_a_table_whose_paragraph_keeps_the_list_tags_and_children() { + let mut engine = TextEngine::default(); + // `c6-tagbullet-midtab.xml`: the table's paragraph holds the tag and bullet, its cells none. + let mut editor = open(&mut engine, &summary("c6-tagbullet-stab2")); + place(&mut editor, (1, Offset::Back(4))); + press(&mut editor, &mut engine, Key::Tab); + let nodes = editor.active_outline().document.nodes(); + let ParagraphContent::Table(table) = &nodes[1].content else { + panic!("{:?}", nodes[1].content) + }; + assert_eq!( + (nodes[1].level, nodes[1].lists.len(), nodes[1].tags.len()), + (1, 1, 1) + ); + for cell in table.rows.iter().flat_map(|row| &row.cells) { + let text = cell.paragraphs[0].text().unwrap(); + assert!(cell.paragraphs[0].lists.is_empty() && text.tags.is_empty()); + } + // `c10-tab-parent-1.xml`: the children stay under the table's paragraph. + let mut editor = open(&mut engine, &summary("c10-tab-parent-0")); + place(&mut editor, (1, Offset::End)); + press(&mut editor, &mut engine, Key::Tab); + let nodes = editor.active_outline().document.nodes(); + assert!(matches!(nodes[1].content, ParagraphContent::Table(_))); + assert_eq!((nodes[2].parent, nodes[2].level), (Some(nodes[1].id), 2)); +} diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs index 7fa16adbe80b63b1eeec2aba3727f9470645698b..0f6b08ba1146d4611dd3d96c556f5f009e1bbbd6 100644 --- a/crates/canvas/src/editor/format.rs +++ b/crates/canvas/src/editor/format.rs @@ -52,13 +52,19 @@ pub enum Alignment { Right, } -/// OneNote 2010's default tags whose stored definition local evidence shows: To Do from a -/// native Ctrl+1 (`corpus/paragraph-edit/reconciliation/keyboard`), Question from notebooks -/// OneNote tagged (`corpus/private/exact-native`). +/// OneNote 2010's default tags, Ctrl+1 to Ctrl+9, as it stores their definitions +/// (`evidence/structural-edits/tags/tags.one`); declared in that order, their action types. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NoteTag { ToDo, + Important, Question, + RememberForLater, + Definition, + Highlight, + Contact, + Address, + PhoneNumber, } /// What the selection shows on the toolbar. @@ -112,20 +118,38 @@ impl Toggle { } impl NoteTag { - const ALL: [Self; 2] = [Self::ToDo, Self::Question]; + pub(super) const ALL: [Self; 9] = [ + Self::ToDo, + Self::Important, + Self::Question, + Self::RememberForLater, + Self::Definition, + Self::Highlight, + Self::Contact, + Self::Address, + Self::PhoneNumber, + ]; - fn definition(self) -> Definition { - let (label, action_type, shape) = match self { - Self::ToDo => ("To Do", 0, CHECKBOX), - Self::Question => ("Question", 2, 15), + pub(super) fn definition(self) -> Definition { + let (label, shape, highlight) = match self { + Self::ToDo => ("To Do", CHECKBOX, None), + Self::Important => ("Important", 13, None), + Self::Question => ("Question", 15, None), + Self::RememberForLater => ("Remember for later", 0, Some(0x0000_ffff)), + Self::Definition => ("Definition", 0, Some(0x0000_ff00)), + Self::Highlight => ("Highlight", 136, None), + Self::Contact => ("Contact", 118, None), + Self::Address => ("Address", 23, None), + Self::PhoneNumber => ("Phone number", 18, None), }; Definition { kind: Kind::TagDefinition { label: Some(label.into()), - action_type: Some(action_type), + action_type: Some(self as u16), shape: Some(shape), - color: None, - highlight: None, + // The highlighting tags also set black text. + color: highlight.map(|_| 0), + highlight, }, format: Format::default(), } @@ -391,7 +415,7 @@ impl CanvasEditor { ) -> Result<(), EditorError> { let outline = self.active_outline(); let (id, title, selection) = (outline.id, outline.title, outline.selection); - let (container, range, ends) = selected(&outline.document, selection)?; + let (container, mut range, ends) = selected(&outline.document, selection)?; let mut replacement = outline.document.container(container)?[range.clone()].to_vec(); let mut ranges = covered(&replacement, ends) .map(|(node, range)| (node.id, range)) @@ -434,6 +458,18 @@ impl CanvasEditor { Formatting::Bullets | Formatting::Numbering => { let remove = covered(&replacement, ends) .all(|(node, _)| self.list(node).as_ref() == Some(&command)); + // A list applied after a plain sibling nests under it, as Tab would + // (`evidence/structural-edits/xml/pb-1.xml`); removing it leaves it there. + let nodes = outline.document.container(container)?; + if !remove + && crate::document::previous_sibling(nodes, range.start, &BTreeSet::new()) + .is_some_and(|sibling| nodes[sibling].lists.is_empty()) + && let Some(edit) = + crate::document::indent(nodes, container, range.clone(), false) + { + range = edit.range; + replacement = edit.replacement; + } let wanted = covered(&replacement, ends) .map(|(node, _)| node) .filter(|node| !remove && self.list(node).as_ref() != Some(&command)) @@ -632,10 +668,73 @@ impl CanvasEditor { } } +/// The list a Tab (`deeper`) or Shift+Tab gives a paragraph with a default list, as OneNote +/// 2010 steps • to â—‹ and 1. to a. (`evidence/structural-edits/xml/c6-bullet-tab.xml`, +/// `c8-tab-1.xml`), then to â–  as `corpus/private` nests bullets, and to i.; outdenting stops at +/// the first style. +pub(super) fn nested_list(definition: &Definition, deeper: bool) -> Option { + const BULLETS: [(&str, &str, u16); 3] = [ + ("Calibri", "\u{2022}", 1), + ("Courier New", "\u{25cb}", 4), + ("Wingdings", "\u{a7}", 7), + ]; + const SEQUENCES: [char; 3] = ['\0', '\u{4}', '\u{2}']; + let Kind::List { + font, + format: Some(format), + restart, + bullet, + } = &definition.kind + else { + return None; + }; + let step = |index: usize| { + if deeper { + Some((index + 1) % 3) + } else { + index.checked_sub(1) + } + }; + let kind = match bullet { + Some(_) => { + let index = BULLETS.iter().position(|(name, glyph, index)| { + (font.as_deref(), format.as_str(), *bullet) == (Some(*name), *glyph, Some(*index)) + })?; + let (name, glyph, index) = BULLETS[step(index)?]; + Kind::List { + font: Some(name.into()), + format: Some(glyph.into()), + restart: *restart, + bullet: Some(index), + } + } + None => { + let (prefix, rest) = format.split_once('\u{fffd}')?; + let mut rest = rest.chars(); + let sequence = rest.next()?; + let index = SEQUENCES.iter().position(|known| *known == sequence)?; + Kind::List { + font: font.clone(), + format: Some(format!( + "{prefix}\u{fffd}{}{}", + SEQUENCES[step(index)?], + rest.as_str() + )), + restart: *restart, + bullet: None, + } + } + }; + Some(Definition { + kind, + format: definition.format.clone(), + }) +} + /// The list OneNote 2010 gives a paragraph with `format`: its Ctrl+. bullet /// (`corpus/paragraph-edit/reconciliation/keyboard`), or the `##.` arabic numbering it stores /// from its COM interface (`corpus/outline-edit/tree`). -fn list_definition(numbering: bool, format: &Format) -> Definition { +pub(super) fn list_definition(numbering: bool, format: &Format) -> Definition { let font_size = Some(format.font_size.unwrap_or(11.0)); if numbering { Definition { @@ -1007,9 +1106,10 @@ mod tests { .as_ptr(); editor.select([at(1, 1); 2].into()).unwrap(); editor.format(&mut engine, Formatting::Numbering).unwrap(); + // An unnumbered sibling leaves the count alone (`evidence/structural-edits/xml/c8-bs-1.xml`). assert_eq!( numbers(&mut engine, &editor), - [Some(1), None, Some(1), Some(2)] + [Some(1), None, Some(2), Some(3)] ); assert_eq!( editor.active_outline().shaped.paragraphs[0] @@ -1036,7 +1136,7 @@ mod tests { editor.format(&mut engine, Formatting::Outdent).unwrap(); assert_eq!( numbers(&mut engine, &editor), - [Some(1), Some(2), None, Some(1)] + [Some(1), Some(2), None, Some(3)] ); editor.select(all).unwrap(); let state = editor.format_state().unwrap(); @@ -1205,6 +1305,57 @@ mod tests { assert_eq!(editor.active_outline().document, original); } + #[test] + fn the_nine_default_tags_are_onenotes_stored_definitions_and_all_draw() { + use onestore::{RevisionIndex, Store, document::Document}; + let bytes = include_bytes!("../../../../evidence/structural-edits/tags/tags.one"); + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (space, _) = document.pages().unwrap()[0]; + let stored = Page::from_space(&document, space).unwrap().definitions; + let stored = stored + .values() + .filter(|definition| matches!(definition.kind, Kind::TagDefinition { .. })) + .collect::>(); + assert_eq!(stored.len(), NoteTag::ALL.len()); + for tag in NoteTag::ALL { + assert!(stored.contains(&&tag.definition()), "{tag:?}"); + } + let mut engine = TextEngine::default(); + let mut editor = plain(&mut engine, &["tagged"]); + for tag in NoteTag::ALL { + editor.format(&mut engine, Formatting::Tag(tag)).unwrap(); + } + assert_eq!(editor.format_state().unwrap().tags, NoteTag::ALL); + let paragraph = &editor.active_outline().shaped.paragraphs[0]; + use crate::outline::TagIcon; + assert_eq!( + paragraph + .tags + .iter() + .map(|tag| tag.icon) + .collect::>(), + [ + TagIcon::CheckBox { checked: false }, + TagIcon::Star, + TagIcon::Question, + TagIcon::Highlight, + TagIcon::Contact, + TagIcon::Address, + TagIcon::Phone, + ] + ); + // Remember for later and Definition draw no symbol; the newer one's green marks the text. + assert!( + paragraph + .text + .backgrounds() + .all(|(_, color)| color == 0x0000_ff00) + ); + assert!(paragraph.text.backgrounds().next().is_some()); + } + #[test] fn commands_reach_every_cell_a_selection_crosses() { let mut engine = TextEngine::default(); diff --git a/crates/canvas/src/editor/page.rs b/crates/canvas/src/editor/page.rs index ff0d93adc401e79bb472dd0e42953ebc38a25e0f..bc45ba4267550f186b35a88b473eced13df66a53 100644 --- a/crates/canvas/src/editor/page.rs +++ b/crates/canvas/src/editor/page.rs @@ -47,6 +47,19 @@ pub struct ReadOnlyObject { } impl Content { + /// A picture drawn as a placeholder because it has no data, size or decodable pixels. + pub(crate) fn unavailable( + source: PageObject, + engine: &mut TextEngine, + ) -> Result { + Ok(Self::ReadOnly(ReadOnlyObject::new( + source, + [0.0; 2], + "Image unavailable\nRead-only", + engine, + )?)) + } + pub(super) fn layout(&self) -> Option<(onestore::ExGuid, &onestore::document::Layout)> { match self { Self::Outline { source, .. } => Some((source.id, &source.layout)), @@ -290,12 +303,7 @@ pub(crate) fn build( || source.layout.max_width.is_none() || source.layout.max_height.is_none() { - objects.push(Content::ReadOnly(ReadOnlyObject::new( - object, - page.margin_origin, - "Image unavailable\nRead-only", - engine, - )?)); + objects.push(Content::unavailable(object, engine)?); continue; } let origin = [ diff --git a/crates/canvas/src/editor/table.rs b/crates/canvas/src/editor/table.rs index 0b1f5ec886d4316a0fe0b5799a6941558e55113a..e12e71c9de62a8e71238a8174ffcee8b3795454a 100644 --- a/crates/canvas/src/editor/table.rs +++ b/crates/canvas/src/editor/table.rs @@ -101,12 +101,23 @@ impl CanvasEditor { Paragraph::new(String::new(), format.clone()), ], )?; + let id = new_id()?; + // The table's paragraph holds the list, tags and children (`evidence/structural-edits/ + // xml/c6-*-midtab.xml`, `c10-tab-parent-1.xml`). + let children = split.replacement.split_off(2); + let head = &mut split.replacement[0]; + let lists = std::mem::take(&mut head.lists); + let mut tags = std::mem::take(&mut head.tags); + tags.append(&mut head.text_mut().unwrap().tags); + let collapsed = std::mem::take(&mut split.replacement[1].collapsed); + let tail = split.replacement[1].id; let cells = split .replacement .drain(..) .map(|mut paragraph| { paragraph.level = 1; paragraph.parent = None; + paragraph.lists.clear(); Ok(TableCell { id: new_id()?, layout: Default::default(), @@ -118,15 +129,15 @@ impl CanvasEditor { }) .collect::, EditError>>()?; let wrapper = PageParagraph { - id: new_id()?, + id, parent: source.parent, level: source.level, style: None, format: source.format.clone(), - lists: Vec::new(), - tags: Vec::new(), + lists, + tags, media: Default::default(), - collapsed: false, + collapsed, content: ParagraphContent::Table(onestore::page::Table { id: new_id()?, columns: vec![ @@ -146,6 +157,14 @@ impl CanvasEditor { }), }; split.replacement.push(wrapper); + split + .replacement + .extend(children.into_iter().map(|mut child| { + if child.parent == Some(tail) { + child.parent = Some(id); + } + child + })); return self.commit( engine, split, @@ -261,7 +280,7 @@ impl CanvasEditor { .leaf(focus.paragraph) .ok_or(EditError::InvalidRange)?; let Some(cell) = cell.filter(|_| anchor == focus) else { - return self.insert(engine, "\n"); + return self.split(engine); }; let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?; let ParagraphContent::Table(table) = &location.node.content else { @@ -284,7 +303,7 @@ impl CanvasEditor { && local + 1 == row.cells[location.column].paragraphs.len() && focus.offset == text.utf16_offset(text.text().len())?; if !exit && !append { - return self.insert(engine, "\n"); + return self.split(engine); } let mut wrapper = location.node.clone(); let ParagraphContent::Table(table) = &mut wrapper.content else { diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index 8950762c4d5376b2cb8e35c55ab3a5ca9236e4dc..fa67ea8aae27f9e33de134bd75462a9396c8ff76 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -98,7 +98,12 @@ pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout { const CHECKBOX: &str = include_str!("../../assets/tags/checkbox.svg"); const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg"); +const STAR: &str = include_str!("../../assets/tags/star.svg"); const QUESTION: &str = include_str!("../../assets/tags/question.svg"); +const HIGHLIGHT: &str = include_str!("../../assets/tags/highlight.svg"); +const CONTACT: &str = include_str!("../../assets/tags/contact.svg"); +const ADDRESS: &str = include_str!("../../assets/tags/address.svg"); +const PHONE: &str = include_str!("../../assets/tags/phone.svg"); const MUSIC: &str = include_str!("../../assets/tags/music.svg"); const EXCLAMATION: &str = include_str!("../../assets/tags/exclamation.svg"); const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg"); @@ -110,7 +115,12 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { match icon { TagIcon::CheckBox { checked: false } => &[CHECKBOX], TagIcon::CheckBox { checked: true } => &[CHECKBOX, CHECKMARK], + TagIcon::Star => &[STAR], TagIcon::Question => &[QUESTION], + TagIcon::Highlight => &[HIGHLIGHT], + TagIcon::Contact => &[CONTACT], + TagIcon::Address => &[ADDRESS], + TagIcon::Phone => &[PHONE], TagIcon::Music => &[MUSIC], TagIcon::Exclamation => &[EXCLAMATION], TagIcon::RedSquare => &[RED_SQUARE], @@ -140,7 +150,7 @@ mod tests { let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap(); - let size = [256, 64]; + let size = [448, 64]; let target = device.create_texture(&wgpu::TextureDescriptor { label: Some("Tag readback test"), size: wgpu::Extent3d { @@ -165,7 +175,12 @@ mod tests { let icons = [ TagIcon::CheckBox { checked: false }, TagIcon::CheckBox { checked: true }, + TagIcon::Star, TagIcon::Question, + TagIcon::Highlight, + TagIcon::Contact, + TagIcon::Address, + TagIcon::Phone, TagIcon::Music, TagIcon::Exclamation, TagIcon::RedSquare, diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index e84242c5532483910e0e4e5d6a848cd8de230eb3..c4a16cb908324020d3f3b2c8bc84d60e9c977236 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -92,10 +92,13 @@ fn append_placeholder(rect: [f32; 4], paper: Paper, primitives: &mut Vec for SceneError { impl PageScene { pub fn new(mut page: Page, engine: &mut TextEngine) -> Result { - let objects = crate::editor::page::build(&mut page, engine, false) + let mut objects = crate::editor::page::build(&mut page, engine, false) .map_err(SceneError::from)? .objects; let pictures = Self::decode_images(&objects, None)?; + pictures.mark_unavailable(&mut objects, engine)?; Ok(Self { reference: Some(objects), ..pictures @@ -154,12 +158,35 @@ impl PageScene { page: Page, engine: &mut TextEngine, ) -> Result<(Self, CanvasEditor), SceneError> { - let editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?; - Ok((Self::decode_images(&editor.objects, Some(&editor))?, editor)) + let mut editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?; + let scene = Self::decode_images(&editor.objects, Some(&editor))?; + scene.mark_unavailable(&mut editor.objects, engine)?; + Ok((scene, editor)) + } + + /// Turns page pictures that did not decode into placeholders; their stored data is kept. + fn mark_unavailable( + &self, + objects: &mut Vec, + engine: &mut TextEngine, + ) -> Result<(), SceneError> { + *objects = std::mem::take(objects) + .into_iter() + .map(|object| match object { + Content::Image(source) + if !self.images.contains_key(&source.id) + && !self.backgrounds.contains_key(&source.id) => + { + Content::unavailable(onestore::page::PageObject::Image(source), engine) + } + object => Ok(object), + }) + .collect::>()?; + Ok(()) } /// Decodes every picture the page draws, page-level ones and those inside outlines, - /// except the template backgrounds it recognises. + /// except the template backgrounds it recognises; one without decodable data is left out. fn decode_images( objects: &[Content], editor: Option<&CanvasEditor>, @@ -215,8 +242,9 @@ impl PageScene { let mut images = std::collections::BTreeMap::new(); let mut bytes = 0_u64; for (id, encoded) in payloads { - let image = RasterImage::decode(encoded.ok_or(SceneError::MissingImage)?) - .map_err(SceneError::Image)?; + let Some(image) = encoded.and_then(|encoded| RasterImage::decode(encoded).ok()) else { + continue; + }; bytes += image.pixels().len() as u64; if bytes > draw::MAX_IMAGE_BYTES { return Err(SceneError::Image(RenderError::ImageBudget)); @@ -284,6 +312,9 @@ impl PageScene { crate::outline::ObjectKind::Unsupported(_) => { append_placeholder(rect, paper, primitives) } + crate::outline::ObjectKind::Picture if !self.images.contains_key(&object.id) => { + append_placeholder(rect, paper, primitives) + } crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {} } if let Some(label) = object.label() { diff --git a/crates/canvas/src/interaction/accessibility.rs b/crates/canvas/src/interaction/accessibility.rs index 3e0f96458c32d063b2ea7c03d18ac012fdeb2d55..4d492df642143804b356cd17102cbd664a778d18 100644 --- a/crates/canvas/src/interaction/accessibility.rs +++ b/crates/canvas/src/interaction/accessibility.rs @@ -597,7 +597,12 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result use crate::outline::TagIcon; let fallback = match tag.icon { TagIcon::CheckBox { .. } => "To do", + TagIcon::Star => "Important", TagIcon::Question => "Question", + TagIcon::Highlight => "Highlight", + TagIcon::Contact => "Contact", + TagIcon::Address => "Address", + TagIcon::Phone => "Phone number", TagIcon::Music => "Music", TagIcon::Exclamation => "Critical", TagIcon::RedSquare => "Project A", diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 7bf2456d61775d98761a23444ee41e2ea0b15f88..4c21f75684fcee6be94b2c1a1f1e4c448b1d1aff 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -819,7 +819,7 @@ impl PageView { self.commit_text(text) } - /// Text committed by an input method or pasted, line breaks included. + /// Text committed by an input method, line breaks included. pub fn commit_text(&mut self, text: String) -> Result { if !self.accepts_text() { return Ok(Response::default()); @@ -828,6 +828,15 @@ impl PageView { self.edited() } + /// Clipboard text; see [`CanvasEditor::paste`]. + pub fn paste(&mut self, text: &str) -> Result { + if !self.accepts_text() { + return Ok(Response::default()); + } + self.editor.paste(&mut self.engine, text)?; + self.edited() + } + /// An input method's marked text; `cursor` is its UTF-8 selection within `text`. pub fn compose(&mut self, text: String, cursor: Option<(usize, usize)>) -> Result { if !self.accepts_text() { diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs index c92155446ba2bf5a49929ed7d0365d3ebed6d1f0..c7353b830f463d24ecd7aa8e736fa6a507e89dfc 100644 --- a/crates/canvas/src/outline.rs +++ b/crates/canvas/src/outline.rs @@ -42,6 +42,8 @@ struct Block { state: (f64, Option), /// A table's vertical coordinates before its offset. rel: Vec, + /// The number the next numbered sibling at the node's level continues from. + count: Option, } /// A layout of root nodes `range` once an edit applies, and how the nodes after them move. @@ -142,6 +144,8 @@ fn verticals<'a>( /// OneNote centers a file's icon and name in a column this wide. const ATTACHMENT_WIDTH: f32 = 54.0; +/// The grey OneNote gives secondary text such as the page date (its `PageDateTime` style). +const PLACEHOLDER: u32 = 0x0080_8080; #[derive(Clone)] pub struct TableLayout { @@ -201,7 +205,12 @@ pub struct ParagraphLayout { #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum TagIcon { CheckBox { checked: bool }, + Star, Question, + Highlight, + Contact, + Address, + Phone, Music, Exclamation, RedSquare, @@ -255,8 +264,16 @@ pub(crate) fn image_size(image: &onestore::page::Image) -> Option<[f32; 2]> { .then_some(size) } +/// The table OneNote writes on every outline it creates, standing in for a missing one. +const DEFAULT_INDENTS: [f32; 4] = [18.0, 0.0, 27.0, 27.0]; + pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result { - if indents.is_empty() || indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 { + let indents = if indents.is_empty() { + &DEFAULT_INDENTS + } else { + indents + }; + if indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 { return Err(LayoutError::InvalidIndentation); } let known = (level as usize).min(indents.len() - 1); @@ -282,8 +299,19 @@ fn follows(number: Option<(u32, bool)>, previous: Option) -> Option { number.map(|(number, restart)| if restart { number } else { next(previous) }) } -/// Removes deeper paragraphs' entries from a flow's `(level, number)` stack of the latest -/// paragraph at each level, and the previous sibling's at `level`, returning that one's number. +/// What the numbered sibling after `node`, numbered `number`, continues from: siblings without +/// a number leave the count alone, as do empty numbered ones after another number +/// (`evidence/structural-edits/xml/c8-bs-1.xml`, `c8-enter-empty-1.xml`, `c2s-num-1.xml`). +fn tally(node: &PageParagraph, number: Option, previous: Option) -> Option { + number + .filter(|_| { + previous.is_none() || node.text().is_some_and(|text| !text.text.text().is_empty()) + }) + .or(previous) +} + +/// Removes deeper paragraphs' entries from a flow's `(level, count)` stack of the latest +/// paragraph at each level, and the previous sibling's at `level`, returning that one's count. fn sibling(siblings: &mut Vec<(u32, Option)>, level: u32) -> Option { while siblings.last().is_some_and(|(deeper, _)| *deeper > level) { siblings.pop(); @@ -296,7 +324,7 @@ fn sibling(siblings: &mut Vec<(u32, Option)>, level: u32) -> Option { /// `number` in a list numbering sequence: 0 arabic, 1 and 2 upper and lower roman, 3 and 4 /// upper and lower letters, as OneNote 2010's `numberSequence`. -fn numeral(sequence: Option, number: u32) -> Result { +pub(crate) fn numeral(sequence: Option, number: u32) -> Result { let roman = |number: u32| { const DIGITS: [(u32, &str); 13] = [ (1000, "M"), @@ -471,8 +499,13 @@ impl ParagraphLayout { else { return Err(LayoutError::InvalidList); }; + let mut color = definition.format.color; let value = match value.split_once('\u{fffd}') { Some((prefix, rest)) => { + // An empty numbered paragraph shows its number as a grey placeholder. + if source.text.text().is_empty() { + color = Some(PLACEHOLDER); + } let current = restart.unwrap_or(next(previous)); number = Some((current, restart.is_some())); let mut rest = rest.chars(); @@ -492,7 +525,7 @@ impl ParagraphLayout { Format { font: font.clone().or_else(|| definition.format.font.clone()), font_size: definition.format.font_size.or(format.font_size), - color: definition.format.color, + color, ..Format::default() }, ); @@ -517,8 +550,13 @@ impl ParagraphLayout { Some(3) => TagIcon::CheckBox { checked: tag.status & 1 != 0, }, + Some(13) => TagIcon::Star, Some(15) => TagIcon::Question, Some(17) => TagIcon::Exclamation, + Some(18) => TagIcon::Phone, + Some(23) => TagIcon::Address, + Some(118) => TagIcon::Contact, + Some(136) => TagIcon::Highlight, Some(100) => TagIcon::RedSquare, Some(101) => TagIcon::YellowSquare, Some(102) => TagIcon::BlueSquare, @@ -702,6 +740,7 @@ impl OutlineLayout { extent: [f32::NEG_INFINITY; 2], state, rel: Vec::new(), + count: None, }; if !hidden { let (space, height, flow, extent) = match &node.content { @@ -836,7 +875,8 @@ impl OutlineLayout { return Err(LayoutError::InvalidSpacing); } } - siblings.push((node.level, number)); + block.count = tally(node, number, previous); + siblings.push((node.level, block.count)); block.state = state; if depth == 0 { result.blocks.push(block); @@ -932,7 +972,7 @@ impl OutlineLayout { for root in (0..range.start).rev() { if nodes[root].level < level { level = nodes[root].level; - siblings.push((level, self.number(nodes, root).map(|(number, _)| number))); + siblings.push((level, self.blocks[root].count)); if level <= 1 { break; } @@ -990,10 +1030,11 @@ impl OutlineLayout { let previous = sibling(&mut siblings, nodes[root].level); let number = self.number(nodes, root); let now = follows(number, previous); - if now != number.map(|(number, _)| number) { + let counted = tally(&nodes[root], now, previous); + if now != number.map(|(number, _)| number) || counted != self.blocks[root].count { renumbered = root + 1; } - siblings.push((nodes[root].level, now)); + siblings.push((nodes[root].level, counted)); if nodes[root].level <= 1 && renumbered <= root { break; } @@ -1678,8 +1719,13 @@ mod tests { }); for (shape, expected) in [ (3, TagIcon::CheckBox { checked: true }), + (13, TagIcon::Star), (15, TagIcon::Question), (17, TagIcon::Exclamation), + (18, TagIcon::Phone), + (23, TagIcon::Address), + (118, TagIcon::Contact), + (136, TagIcon::Highlight), (100, TagIcon::RedSquare), (101, TagIcon::YellowSquare), (102, TagIcon::BlueSquare), diff --git a/crates/canvas/tests/reopen.rs b/crates/canvas/tests/reopen.rs new file mode 100644 index 0000000000000000000000000000000000000000..cc47fa96d07d1fbfee968071640d25894ff38f64 --- /dev/null +++ b/crates/canvas/tests/reopen.rs @@ -0,0 +1,181 @@ +#![cfg(feature = "gpu")] + +use canvas::{gpu::page::PageScene, layout::TextEngine}; +use onestore::{ + ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store, + document::Document, + page::{Page, PageObject, ParagraphContent}, +}; +use std::{fs, path::Path}; + +fn pages(section: &[u8]) -> Vec { + let store = Store::parse(section).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + spaces(&document) + .into_iter() + .map(|space| Page::from_space(&document, space).unwrap()) + .collect() +} + +fn spaces(document: &Document<'_>) -> Vec { + let mut spaces: Vec<_> = document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| space) + .collect(); + spaces.dedup(); + spaces +} + +/// What `notebook::session::Section::import_page` writes: a new titled page, then the +/// editor's body objects saved onto it. +#[test] +fn an_imported_editor_page_reopens_with_its_title_editable() { + let section = onestore::create_section("reopen.one", "Body 🦀 é", "Author").unwrap(); + let mut engine = TextEngine::default(); + let (_, editor) = PageScene::from_page(pages(§ion).remove(0), &mut engine).unwrap(); + let copy = editor.page().unwrap().copy().unwrap(); + let creation = PageCreation::new(None, Some("Imported 🦋"), "Author").unwrap(); + let created = PreparedEdit::create_page(§ion, &creation).unwrap(); + let mut after = pages(created.as_bytes()).pop().unwrap(); + after + .objects + .retain(|object| matches!(object, PageObject::Title(_))); + after.objects.extend( + copy.objects + .into_iter() + .filter(|object| !matches!(object, PageObject::Title(_))), + ); + after.definitions = copy.definitions; + let saved = PreparedEdit::page(created.as_bytes(), creation.space(), &after, "Author").unwrap(); + + let reopened = pages(saved.as_bytes()).pop().unwrap(); + let (_, editor) = PageScene::from_page(reopened.clone(), &mut engine).unwrap(); + assert_eq!(editor.page().unwrap(), reopened); + let texts = |title: bool| -> Vec { + editor + .outlines() + .iter() + .filter(|outline| outline.title == title) + .map(|outline| { + outline + .document() + .paragraphs() + .next() + .unwrap() + .text() + .to_string() + }) + .collect() + }; + assert_eq!(texts(true), ["Imported 🦋"]); + assert_eq!(texts(false), ["Body 🦀 é"]); +} + +#[test] +fn every_corpus_page_opens() { + fn sections(dir: &Path, found: &mut Vec) { + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + sections(&path, found); + } else if path.extension().is_some_and(|extension| extension == "one") { + found.push(path); + } + } + } + let mut found = Vec::new(); + sections( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"), + &mut found, + ); + let mut engine = TextEngine::default(); + let (mut pages, mut failures) = (0, Vec::new()); + for path in found { + let bytes = fs::read(&path).unwrap(); + // Malformed and encrypted sections are the reader's tests' concern. + let Ok(store) = Store::parse(&bytes) else { + continue; + }; + let Ok(index) = RevisionIndex::parse(&store) else { + continue; + }; + let Ok(document) = Document::parse(&index) else { + continue; + }; + for space in spaces(&document) { + pages += 1; + let result = Page::from_space(&document, space) + .map_err(|error| format!("{error:?}")) + .and_then(|page| { + PageScene::from_page(page, &mut engine).map_err(|error| format!("{error:?}")) + }); + if let Err(error) = result { + failures.push(format!("{} {space}: {error}", path.display())); + } + } + } + assert!(pages > 2000, "{pages}"); + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// A page opens whatever its pictures hold, and saving keeps their stored data. +#[test] +fn undecodable_pictures_show_placeholders_and_keep_their_data() { + let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"); + let section = |path: &str| pages(&fs::read(corpus.join(path)).unwrap()); + let mut engine = TextEngine::default(); + + // A PNG whose checksum does not match, inside an outline. + let page = section("writer/complex-01/notebook/synthetic.one") + .into_iter() + .find(|page| page.title.starts_with("Fictitious")) + .unwrap(); + let picture = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => { + outline + .paragraphs + .iter() + .find_map(|paragraph| match ¶graph.content { + ParagraphContent::Image(image) => Some(image.id), + _ => None, + }) + } + _ => None, + }) + .unwrap(); + let (scene, editor) = PageScene::from_page(page.clone(), &mut engine).unwrap(); + assert!(scene.image(picture).is_none()); + assert_eq!(editor.page().unwrap(), page); + + // A TIFF on the page renders; with its data damaged it becomes the placeholder. + let mut page = section("media-edit/candidate/Features.one") + .into_iter() + .find(|page| page.title == "Image tiff") + .unwrap(); + let (scene, _) = PageScene::from_page(page.clone(), &mut engine).unwrap(); + let PageObject::Image(tiff) = page + .objects + .iter_mut() + .find(|object| matches!(object, PageObject::Image(_))) + .unwrap() + else { + unreachable!() + }; + assert!(scene.image(tiff.id).is_some()); + tiff.bytes = Some(b"not a picture".as_slice().into()); + let (scene, editor) = PageScene::from_page(page.clone(), &mut engine).unwrap(); + assert_eq!( + scene + .read_only(Some(&editor)) + .map(|object| object.message) + .collect::>(), + ["Image unavailable\nRead-only"] + ); + assert_eq!(editor.page().unwrap(), page); +} diff --git a/crates/draw/Cargo.toml b/crates/draw/Cargo.toml index dfa4dd091cce7759589636e24938938f2ff4ce83..7e86e37a3e8dcabe7d4db7bdacbc99ef969e1f67 100644 --- a/crates/draw/Cargo.toml +++ b/crates/draw/Cargo.toml @@ -13,7 +13,7 @@ render = ["dep:bytemuck", "dep:image", "dep:roxmltree", "dep:swash", "dep:wgpu"] parley.workspace = true bytemuck = { version = "1.25", features = ["derive"], optional = true } -image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"], optional = true } +image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true } roxmltree = { version = "0.21", optional = true } swash = { version = "0.2.10", optional = true } wgpu = { workspace = true, optional = true } diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index b969bbbbabd7cbf22d80dc2a11019cd66601e0a8..30ad19fe24af865fbf3591ee2d6430bf25c0b103 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -624,11 +624,22 @@ impl Page { Ok(width) }) .transpose()?; + let title_text = |node: &Element<'_>| { + node.extra.first().is_some_and(|fields| { + fields.iter().any(|field| { + field.id == 0x88001cb4 && matches!(field.value, FieldValue::NoData) + }) + }) + }; let mut outline = Outline { id, - title: fields.iter().any(|field| { - field.id == 0x88001cb4 && matches!(field.value, FieldValue::NoData) - }), + // OneNote flags the outline and its paragraph; `PageCreation` flags only + // the paragraph, and OneNote still shows that outline as the title. + title: title_text(node) + || node + .children + .iter() + .any(|id| revision.nodes.get(id).is_some_and(title_text)), min_width, layout: node.layout.clone(), indents: indents.clone(), diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index c2584468a6c03d6b81186e02779023cb5b04e81d..751f1ccac8658b8b33834ac22137e461d131c91a 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -39,10 +39,5 @@ pub const UNDO: &[&str] = art!("icons/undo"); pub const ZOOM_IN: &[&str] = art!("icons/zoom-in"); pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out"); -pub const TAG_STAR: &[&str] = art!("tags/star"); pub const TAG_REMEMBER: &[&str] = art!("tags/remember"); pub const TAG_DEFINITION: &[&str] = art!("tags/definition"); -pub const TAG_HIGHLIGHT: &[&str] = art!("tags/highlight"); -pub const TAG_CONTACT: &[&str] = art!("tags/contact"); -pub const TAG_ADDRESS: &[&str] = art!("tags/address"); -pub const TAG_PHONE: &[&str] = art!("tags/phone"); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index f0f6dc1ad786ad90aa3fd24797a46458b422d086..eaf472b951e237188328342d1d7dd25b0a98257f 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1076,27 +1076,27 @@ impl State { }); }); divider(ui, "text", theme); - let tags: [(&[&str], Option); 9] = [ + let tags: [(&[&str], NoteTag); 9] = [ ( tag_sources(TagIcon::CheckBox { checked: false }), - Some(NoteTag::ToDo), + NoteTag::ToDo, ), - (art::TAG_STAR, None), - (tag_sources(TagIcon::Question), Some(NoteTag::Question)), - (art::TAG_REMEMBER, None), - (art::TAG_DEFINITION, None), - (art::TAG_HIGHLIGHT, None), - (art::TAG_CONTACT, None), - (art::TAG_ADDRESS, None), - (art::TAG_PHONE, None), + (tag_sources(TagIcon::Star), NoteTag::Important), + (tag_sources(TagIcon::Question), NoteTag::Question), + (art::TAG_REMEMBER, NoteTag::RememberForLater), + (art::TAG_DEFINITION, NoteTag::Definition), + (tag_sources(TagIcon::Highlight), NoteTag::Highlight), + (tag_sources(TagIcon::Contact), NoteTag::Contact), + (tag_sources(TagIcon::Address), NoteTag::Address), + (tag_sources(TagIcon::Phone), NoteTag::PhoneNumber), ]; group(ui, "tags", |ui| { for (index, tags) in tags.chunks(5).enumerate() { row(ui, index, |ui| { for (column, (icon, tag)) in tags.iter().enumerate() { - let lit = tag.is_some_and(|tag| state.tags.contains(&tag)); + let lit = state.tags.contains(tag); if ui::shell::tool_button(ui, column, icon, [1.0; 4], lit).clicked { - command = tag.map(Formatting::Tag); + command = Some(Formatting::Tag(*tag)); } } if index == 1 { @@ -1448,7 +1448,7 @@ impl State { Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?, Command::Page(Request::Paste) => { let text = self.clipboard.get_text()?; - let response = self.view.commit_text(text)?; + let response = self.view.paste(&text)?; self.respond(response); } Command::Page(Request::CharacterPalette) => macos::show_character_palette(), -- 2.54.0