authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 22:23:46-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 04:23:49-07:00
log05027137f9f30b0179a4f73df88c69aaf898d2de
tree93f6689dc514d87ffd6ba74ad096c25c53a7dfa0
parent263f6a1b7979ef2640fe4d7cfba53c4b9974b442
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

28 files changed, 2092 insertions(+), 344 deletions(-)

Cargo.lock+27
......@@ -792,6 +792,12 @@ version = "2.5.0"
792792source = "registry+https://github.com/rust-lang/crates.io-index"
793793checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
794794
795[[package]]
796name = "fax"
797version = "0.2.7"
798source = "registry+https://github.com/rust-lang/crates.io-index"
799checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
800
795801[[package]]
796802name = "fdeflate"
797803version = "0.3.7"
......@@ -1229,6 +1235,7 @@ dependencies = [
12291235 "moxcms",
12301236 "num-traits",
12311237 "png",
1238 "tiff",
12321239 "zune-core",
12331240 "zune-jpeg",
12341241]
......@@ -2282,6 +2289,12 @@ version = "0.1.30"
22822289source = "registry+https://github.com/rust-lang/crates.io-index"
22832290checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
22842291
2292[[package]]
2293name = "quick-error"
2294version = "2.0.1"
2295source = "registry+https://github.com/rust-lang/crates.io-index"
2296checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
2297
22852298[[package]]
22862299name = "quick-xml"
22872300version = "0.41.0"
......@@ -2927,6 +2940,20 @@ dependencies = [
29272940 "syn 3.0.5",
29282941]
29292942
2943[[package]]
2944name = "tiff"
2945version = "0.11.3"
2946source = "registry+https://github.com/rust-lang/crates.io-index"
2947checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
2948dependencies = [
2949 "fax",
2950 "flate2",
2951 "half",
2952 "quick-error",
2953 "weezl",
2954 "zune-jpeg",
2955]
2956
29302957[[package]]
29312958name = "tiny-skia"
29322959version = "0.11.4"
crates/canvas/assets/tags/address.svg created+34
......@@ -0,0 +1,34 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#9a4a2a"/>
5 <stop offset="1" stop-color="#7a3218"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#b58a3a"/>
9 <stop offset="1" stop-color="#8a6420"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#fff1b0"/>
13 <stop offset="1" stop-color="#f2c25c"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#e0672e"/>
17 <stop offset="1" stop-color="#a8401a"/>
18 </linearGradient>
19 <linearGradient id="layer4" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
20 <stop offset="0" stop-color="#9a5a1e"/>
21 <stop offset="1" stop-color="#6e3a10"/>
22 </linearGradient>
23 <linearGradient id="layer5" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
24 <stop offset="0" stop-color="#e3a54c"/>
25 <stop offset="1" stop-color="#c07a26"/>
26 </linearGradient>
27 </defs>
28 <path d="M10.5 1.5H13V5.5L10.5 3.5Z" fill="url(#layer0)"/>
29 <path d="M3 7H13V15H3Z" fill="url(#layer1)"/>
30 <path d="M4 7.5H12V14H4Z" fill="url(#layer2)"/>
31 <path d="M8 0.75L15.25 8L14 9.25L8 3.25L2 9.25L0.75 8Z" fill="url(#layer3)"/>
32 <path d="M6.75 9.5H9.25V14H6.75Z" fill="url(#layer4)"/>
33 <path d="M7.25 10H8.75V14H7.25Z" fill="url(#layer5)"/>
34</svg>
crates/canvas/assets/tags/contact.svg created+25
......@@ -0,0 +1,25 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#8aa3c8"/>
5 <stop offset="1" stop-color="#5d7aa6"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#ffffff"/>
9 <stop offset="1" stop-color="#d3e0f2"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#e39a5c"/>
13 <stop offset="1" stop-color="#b8612a"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#e05a30"/>
17 <stop offset="1" stop-color="#b03a1a"/>
18 </linearGradient>
19 </defs>
20 <path d="M1.5 2.5H14.5A1 1 0 0 1 15.5 3.5V12.5A1 1 0 0 1 14.5 13.5H1.5A1 1 0 0 1 0.5 12.5V3.5A1 1 0 0 1 1.5 2.5Z" fill="url(#layer0)"/>
21 <path d="M1.75 3.5H14.25A0.25 0.25 0 0 1 14.5 3.75V12.25A0.25 0.25 0 0 1 14.25 12.5H1.75A0.25 0.25 0 0 1 1.5 12.25V3.75A0.25 0.25 0 0 1 1.75 3.5Z" fill="url(#layer1)"/>
22 <path d="M3.5 6.5A1.75 1.75 0 1 0 7 6.5A1.75 1.75 0 1 0 3.5 6.5Z" fill="url(#layer2)"/>
23 <path d="M2.25 11.75C2.25 9.5 3.6 8.75 5.25 8.75C6.9 8.75 8.25 9.5 8.25 11.75Z" fill="url(#layer3)"/>
24 <path d="M9.5 5.5H13.5V6.75H9.5ZM9.5 8H13.5V9.25H9.5ZM9.5 10.5H12.5V11.75H9.5Z" fill="#4d6d9c"/>
25</svg>
crates/canvas/assets/tags/highlight.svg created+29
......@@ -0,0 +1,29 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#b23a64"/>
5 <stop offset="1" stop-color="#8c2248"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#7a2a40"/>
9 <stop offset="1" stop-color="#5a1a2c"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#ff9ec0"/>
13 <stop offset="1" stop-color="#e0487c"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#e76b98"/>
17 <stop offset="1" stop-color="#c0305e"/>
18 </linearGradient>
19 <linearGradient id="layer4" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
20 <stop offset="0" stop-color="#ff5f8f"/>
21 <stop offset="1" stop-color="#e0205a"/>
22 </linearGradient>
23 </defs>
24 <path d="M12.31 1.14L14.86 3.69L7.01 11.54L4.46 8.99Z" fill="url(#layer0)"/>
25 <path d="M5.35 7.89L8.11 10.65L4.55 13.04L2.96 11.45Z" fill="url(#layer1)"/>
26 <path d="M12.31 2.13L13.87 3.69L7.01 10.55L5.45 8.99Z" fill="url(#layer2)"/>
27 <path d="M12.31 2.13L13.87 3.69L12.17 5.38L10.62 3.83Z" fill="url(#layer3)"/>
28 <path d="M5.45 8.99L7.01 10.55L4.64 12.14L3.86 11.36Z" fill="url(#layer4)"/>
29</svg>
crates/canvas/assets/tags/phone.svg created+14
......@@ -0,0 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#7f8b99"/>
5 <stop offset="1" stop-color="#56606d"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#f4f7fb"/>
9 <stop offset="1" stop-color="#b3c1d3"/>
10 </linearGradient>
11 </defs>
12 <path d="M12.86 14.11L12.59 14.07L12.31 14.03L12.04 13.98L11.77 13.92L11.49 13.86L11.22 13.79L10.96 13.72L10.69 13.64L10.42 13.56L10.16 13.46L9.9 13.37L9.64 13.27L9.38 13.16L9.13 13.04L8.88 12.92L8.63 12.8L8.38 12.67L8.14 12.53L7.9 12.39L7.66 12.25L7.43 12.09L7.2 11.94L6.97 11.78L6.75 11.61L6.53 11.44L6.31 11.26L6.1 11.08L5.89 10.89L5.69 10.7L5.49 10.51L5.3 10.31L5.11 10.11L4.92 9.9L4.74 9.69L4.56 9.47L4.39 9.25L4.22 9.03L4.06 8.8L3.91 8.57L3.75 8.34L3.61 8.1L3.47 7.86L3.33 7.62L3.2 7.37L3.08 7.12L2.96 6.87L2.84 6.62L2.73 6.36L2.63 6.1L2.54 5.84L2.44 5.58L2.36 5.31L2.28 5.04L2.21 4.78L2.14 4.51L2.08 4.23L2.02 3.96L1.97 3.69L1.93 3.41L1.89 3.14L7.45 2.45L7.47 2.61L7.5 2.76L7.52 2.91L7.55 3.06L7.59 3.21L7.63 3.36L7.67 3.51L7.71 3.66L7.76 3.81L7.81 3.96L7.86 4.1L7.77 4.3L7.44 4.62L6.98 5.02L6.51 5.45L6.15 5.85L5.99 6.17L6.07 6.37L6.18 6.55L6.29 6.73L6.4 6.91L6.52 7.09L6.65 7.26L6.77 7.43L6.9 7.6L7.04 7.76L7.18 7.92L7.32 8.08L7.46 8.24L7.61 8.39L7.76 8.54L7.92 8.68L8.08 8.82L8.24 8.96L8.4 9.1L8.57 9.23L8.74 9.35L8.91 9.48L9.09 9.6L9.27 9.71L9.45 9.82L9.63 9.93L9.83 10.01L10.15 9.85L10.55 9.49L10.98 9.02L11.38 8.56L11.7 8.23L11.9 8.14L12.04 8.19L12.19 8.24L12.34 8.29L12.49 8.33L12.64 8.37L12.79 8.41L12.94 8.45L13.09 8.48L13.24 8.5L13.39 8.53L13.55 8.55Z" fill="url(#layer0)"/>
13 <path d="M12.13 13.15L12.71 13.23L12.45 13.19L12.2 13.14L11.94 13.09L11.69 13.03L11.44 12.97L11.19 12.9L10.94 12.83L10.69 12.75L10.45 12.66L10.2 12.57L9.96 12.48L9.72 12.38L9.48 12.27L9.25 12.16L9.02 12.04L8.79 11.92L8.56 11.8L8.34 11.66L8.12 11.53L7.9 11.39L7.68 11.24L7.47 11.09L7.26 10.93L7.06 10.77L6.86 10.61L6.66 10.44L6.47 10.27L6.28 10.09L6.09 9.91L5.91 9.72L5.73 9.53L5.56 9.34L5.39 9.14L5.23 8.94L5.07 8.74L4.91 8.53L4.76 8.32L4.61 8.1L4.47 7.88L4.34 7.66L4.2 7.44L4.08 7.21L3.96 6.98L3.84 6.75L3.73 6.52L3.62 6.28L3.52 6.04L3.43 5.8L3.34 5.55L3.25 5.31L3.17 5.06L3.1 4.81L3.03 4.56L2.97 4.31L2.91 4.06L2.86 3.8L2.81 3.55L2.77 3.29L2.85 3.87L6.72 3.4L6.63 2.73L6.66 2.9L6.69 3.07L6.72 3.24L6.76 3.41L6.8 3.58L6.85 3.75L6.9 3.91L6.95 4.08L7.01 4.24L6.95 4.07L7.06 3.81L6.87 3.99L6.41 4.38L5.91 4.85L5.43 5.37L5.06 6.14L5.31 6.75L5.45 6.99L5.57 7.19L5.7 7.38L5.83 7.57L5.96 7.76L6.1 7.95L6.24 8.13L6.39 8.31L6.54 8.48L6.69 8.66L6.85 8.82L7.01 8.99L7.18 9.15L7.34 9.31L7.52 9.46L7.69 9.61L7.87 9.76L8.05 9.9L8.24 10.04L8.43 10.17L8.62 10.3L8.81 10.43L9.01 10.55L9.25 10.69L9.86 10.94L10.63 10.57L11.15 10.09L11.62 9.59L12.01 9.13L12.19 8.94L11.93 9.05L11.76 8.99L11.92 9.05L12.09 9.1L12.25 9.15L12.42 9.2L12.59 9.24L12.76 9.28L12.93 9.31L13.1 9.34L13.27 9.37L12.6 9.28Z" fill="url(#layer1)"/>
14</svg>
crates/canvas/assets/tags/star.svg created+14
......@@ -0,0 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#c8a049"/>
5 <stop offset="1" stop-color="#a57a2c"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#fff4c4"/>
9 <stop offset="1" stop-color="#f1c35a"/>
10 </linearGradient>
11 </defs>
12 <path d="M8 1.35L9.94 6.08L15.04 6.46L11.14 9.77L12.35 14.74L8 12.05L3.65 14.74L4.86 9.77L0.96 6.46L6.06 6.08Z" fill="url(#layer0)"/>
13 <path d="M8 3.72L9.32 6.94L12.78 7.2L10.13 9.44L10.96 12.82L8 10.99L5.04 12.82L5.87 9.44L3.22 7.2L6.68 6.94Z" fill="url(#layer1)"/>
14</svg>
crates/canvas/src/date.rs+11-7
......@@ -72,13 +72,17 @@ impl PageDate {
7272 if source.title || source.paragraphs.is_empty() || source.paragraphs.len() > 2 {
7373 return Err(EditError::UnsupportedContent.into());
7474 }
75 let document = TextDocument::from_nodes(source.paragraphs.clone())?;
76 document.validate_flat()?;
77 if source
78 .paragraphs
79 .iter()
80 .any(|paragraph| paragraph.text().unwrap().text.spans().len() != 1)
81 {
75 TextDocument::from_nodes(source.paragraphs.clone())?;
76 // A date field is one plain run in a plain paragraph.
77 if source.paragraphs.iter().any(|paragraph| {
78 !paragraph.lists.is_empty()
79 || !paragraph.tags.is_empty()
80 || paragraph.collapsed
81 || paragraph.parent.is_some()
82 || paragraph
83 .text()
84 .is_none_or(|text| !text.tags.is_empty() || text.text.spans().len() != 1)
85 }) {
8286 return Err(EditError::UnsupportedContent.into());
8387 }
8488 let layout = outline_layout(&source, engine, definitions, TITLE_WIDTH)?;
crates/canvas/src/document.rs+284-52
......@@ -243,19 +243,158 @@ fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator<Item = usize>
243243 })
244244}
245245
246pub(crate) fn validate_flat<'a>(
247 mut nodes: impl Iterator<Item = &'a PageParagraph>,
248) -> Result<(), EditError> {
249 if nodes.any(|node| {
250 !node.lists.is_empty()
251 || !node.tags.is_empty()
252 || node.collapsed
253 || node.parent.is_some()
254 || node.text().is_none_or(|text| !text.tags.is_empty())
255 }) {
256 return Err(EditError::UnsupportedContent);
246/// The index after `nodes[index]`'s last descendant. Descendants follow their ancestor
247/// contiguously; a paragraph only an outline group indents has no parent to descend from.
248pub(crate) fn subtree_end(nodes: &[PageParagraph], index: usize) -> usize {
249 let mut members = BTreeSet::from([nodes[index].id]);
250 index
251 + 1
252 + nodes[index + 1..]
253 .iter()
254 .take_while(|node| {
255 node.parent.is_some_and(|parent| members.contains(&parent))
256 && members.insert(node.id)
257 })
258 .count()
259}
260
261/// The nodes from `from` on that descend from a paragraph in `moves` or `shifts`, rebuilt so
262/// the children of each paragraph in `moves` belong to its new parent, one level below it, and
263/// every subtree keeps its depth below its root; ends at the last node that changes.
264fn adopt(
265 nodes: &[PageParagraph],
266 from: usize,
267 moves: &BTreeMap<ExGuid, &PageParagraph>,
268 mut shifts: BTreeMap<ExGuid, i64>,
269) -> Result<Vec<PageParagraph>, EditError> {
270 let mut adopted = Vec::new();
271 let mut changed = 0;
272 for node in &nodes[from..] {
273 let Some(parent) = node.parent else { break };
274 let mut node = node.clone();
275 let shift = match (moves.get(&parent), shifts.get(&parent)) {
276 (Some(holder), _) => {
277 node.parent = Some(holder.id);
278 i64::from(holder.level) + 1 - i64::from(node.level)
279 }
280 (None, Some(shift)) => *shift,
281 (None, None) => break,
282 };
283 node.level = u32::try_from(i64::from(node.level) + shift)
284 .map_err(|_| EditError::InvalidStructure)?;
285 shifts.insert(node.id, shift);
286 if shift != 0 || moves.contains_key(&parent) {
287 changed = adopted.len() + 1;
288 }
289 adopted.push(node);
257290 }
258 Ok(())
291 adopted.truncate(changed);
292 Ok(adopted)
293}
294
295/// The sibling `nodes[index]` follows, passing over `skipped` siblings, when everything
296/// between them descends from it or from a skipped paragraph.
297pub(crate) fn previous_sibling(
298 nodes: &[PageParagraph],
299 index: usize,
300 skipped: &BTreeSet<ExGuid>,
301) -> Option<usize> {
302 let node = &nodes[index];
303 let sibling = (0..index).rev().find(|&at| {
304 nodes[at].level <= node.level
305 && !(nodes[at].level == node.level && skipped.contains(&nodes[at].id))
306 })?;
307 if nodes[sibling].level != node.level || nodes[sibling].parent != node.parent {
308 return None;
309 }
310 let mut members = BTreeSet::from([nodes[sibling].id]);
311 nodes[sibling + 1..index]
312 .iter()
313 .all(|node| {
314 let inside = skipped.contains(&node.id)
315 || node.parent.is_some_and(|parent| members.contains(&parent));
316 members.insert(node.id);
317 inside
318 })
319 .then_some(sibling)
320}
321
322/// Tab or Shift+Tab on `range` of `nodes` as OneNote does, moving each paragraph with its
323/// subtree: indenting makes a paragraph the last child of its previous sibling, or without one
324/// indents it within its group; outdenting a child makes it its parent's sibling, adopting the
325/// siblings after it. None when nothing moves.
326pub(crate) fn indent(
327 nodes: &[PageParagraph],
328 container: Option<ExGuid>,
329 range: Range<usize>,
330 outdent: bool,
331) -> Option<DocumentEdit> {
332 let selected = nodes[range.clone()]
333 .iter()
334 .map(|node| node.id)
335 .collect::<BTreeSet<_>>();
336 let mut end = range.end;
337 let mut tops = BTreeMap::new();
338 let mut adopters = BTreeMap::new();
339 for index in range.clone() {
340 let node = &nodes[index];
341 if node.parent.is_some_and(|parent| selected.contains(&parent)) {
342 continue;
343 }
344 end = end.max(subtree_end(nodes, index));
345 if !outdent {
346 let parent = previous_sibling(nodes, index, &selected).map(|at| nodes[at].id);
347 tops.insert(node.id, (parent.or(node.parent), 1));
348 } else if node.level > 1 {
349 let parent = node
350 .parent
351 .and_then(|id| nodes[..index].iter().rposition(|node| node.id == id))
352 .filter(|&at| nodes[at].level + 1 == node.level);
353 let parent = match parent {
354 Some(at) => {
355 end = end.max(subtree_end(nodes, at));
356 adopters.insert(nodes[at].id, node.id);
357 nodes[at].parent
358 }
359 None => node.parent,
360 };
361 tops.insert(node.id, (parent, -1));
362 }
363 }
364 if tops.is_empty() {
365 return None;
366 }
367 let mut shifts = BTreeMap::new();
368 let replacement = nodes[range.start..end]
369 .iter()
370 .map(|node| {
371 let mut node = node.clone();
372 let shift = match (tops.get(&node.id), node.parent) {
373 (Some(&(parent, shift)), _) => {
374 node.parent = parent;
375 shift
376 }
377 (None, Some(parent))
378 if !selected.contains(&node.id) && adopters.contains_key(&parent) =>
379 {
380 node.parent = Some(adopters[&parent]);
381 0
382 }
383 (None, parent) => parent
384 .and_then(|parent| shifts.get(&parent).copied())
385 .unwrap_or(0),
386 };
387 node.level = node.level.saturating_add_signed(shift);
388 shifts.insert(node.id, shift);
389 node
390 })
391 .collect();
392 Some(DocumentEdit {
393 columns: BTreeMap::new(),
394 container,
395 range: range.start..end,
396 replacement,
397 })
259398}
260399
261400pub(crate) fn container_mut(
......@@ -318,10 +457,6 @@ impl TextDocument {
318457 &self.nodes
319458 }
320459
321 pub(crate) fn validate_flat(&self) -> Result<(), EditError> {
322 validate_flat(self.nodes.iter())
323 }
324
325460 pub fn text_nodes(&self) -> impl Iterator<Item = &PageParagraph> {
326461 leaves(&self.nodes, None).map(|(_, _, node)| node)
327462 }
......@@ -452,6 +587,11 @@ impl TextDocument {
452587 Ok(result)
453588 }
454589
590 /// Replaces `range` as OneNote's typing, Enter and deletion do: the first paragraph keeps
591 /// its identity and properties, and a paragraph the replacement adds takes its level,
592 /// parent, style and lists but no note tags, except that a range's last paragraph stays
593 /// itself when the replacement ends in one. The last paragraph holds the children of the
594 /// paragraphs the edit removes or splits.
455595 pub(crate) fn replace(
456596 &self,
457597 range: Range<TextPosition>,
......@@ -469,13 +609,7 @@ impl TextDocument {
469609 if container != end_container {
470610 return Err(EditError::UnsupportedContent);
471611 }
472 if range.start.paragraph != range.end.paragraph || replacement.len() != 1 {
473 validate_flat(self.container(container)?.iter().enumerate().filter_map(
474 |(index, node)| {
475 ((start..=end).contains(&index) || node.text().is_some()).then_some(node)
476 },
477 ))?;
478 }
612 let nodes = self.container(container)?;
479613 let mut prefix = first.text().unwrap().text.slice(0..range.start.offset)?;
480614 let last_text = &last.text().unwrap().text;
481615 let suffix =
......@@ -484,32 +618,90 @@ impl TextDocument {
484618 prefix.append(replacement.next().ok_or(EditError::InvalidRange)?)?;
485619 let mut head = first.clone();
486620 head.text_mut().unwrap().text = prefix;
487 let mut nodes = vec![head];
488621 let following = replacement.len();
622 let keeps_last = start != end && following > 0;
623 let mut added = Vec::new();
489624 for (index, text) in replacement.enumerate() {
490 if index + 1 == following && range.start.paragraph != range.end.paragraph {
625 let mut next = if keeps_last && index + 1 == following {
491626 let mut end = last.clone();
492627 end.text_mut().unwrap().text = text;
493 nodes.push(end);
628 end
494629 } else {
495630 let mut next = node(text, first.format.clone())?;
496 next.level = first.level;
497631 next.style = first.style;
498 nodes.push(next);
499 }
632 next.lists.clone_from(&first.lists);
633 next
634 };
635 next.parent = first.parent;
636 next.level = first.level;
637 added.push(next);
638 }
639 if let Some(tail) = added.last_mut() {
640 tail.collapsed |= std::mem::take(&mut head.collapsed);
500641 }
642 let tail = added.last_mut().unwrap_or(&mut head);
501643 if !suffix.text().is_empty() {
502 let end = nodes.last_mut().unwrap();
503 end.text_mut().unwrap().text.append(suffix)?;
644 tail.text_mut().unwrap().text.append(suffix)?;
645 }
646 let tail = added.last().unwrap_or(&head);
647 let mut moves = nodes[start + 1..end + usize::from(!keeps_last)]
648 .iter()
649 .map(|node| (node.id, tail))
650 .collect::<BTreeMap<_, _>>();
651 if tail.id != head.id {
652 moves.insert(head.id, tail);
504653 }
654 let shifts = keeps_last
655 .then(|| (last.id, i64::from(first.level) - i64::from(last.level)))
656 .into_iter()
657 .collect();
658 let adopted = adopt(nodes, end + 1, &moves, shifts)?;
505659 Ok(DocumentEdit {
506660 columns: BTreeMap::new(),
507661 container,
508 range: start..end + 1,
509 replacement: nodes,
662 range: start..end + 1 + adopted.len(),
663 replacement: [head].into_iter().chain(added).chain(adopted).collect(),
510664 })
511665 }
512666
667 /// Appends text leaf `lower`'s text to `upper`'s, keeping the upper paragraph's properties
668 /// and giving it the lower one's children; None unless nothing but `upper`'s hidden subtree
669 /// lies between them in one container.
670 pub(crate) fn join(
671 &self,
672 upper: usize,
673 lower: usize,
674 ) -> Result<Option<DocumentEdit>, EditError> {
675 let (container, first, top) = self.leaf(upper).ok_or(EditError::InvalidRange)?;
676 let (end_container, last, bottom) = self.leaf(lower).ok_or(EditError::InvalidRange)?;
677 let nodes = self.container(container)?;
678 if container != end_container
679 || last <= first
680 || last > first + 1 && !(top.collapsed && subtree_end(nodes, first) == last)
681 {
682 return Ok(None);
683 }
684 let mut head = top.clone();
685 let text = head.text_mut().unwrap();
686 // OneNote moves the lower text object into an emptied upper paragraph.
687 if text.text.text().is_empty() {
688 text.id = bottom.text().unwrap().id;
689 }
690 text.text.append(bottom.text().unwrap().text.clone())?;
691 let moves = BTreeMap::from([(bottom.id, &head)]);
692 let adopted = adopt(nodes, last + 1, &moves, BTreeMap::new())?;
693 Ok(Some(DocumentEdit {
694 columns: BTreeMap::new(),
695 container,
696 range: first..last + 1 + adopted.len(),
697 replacement: [head]
698 .into_iter()
699 .chain(nodes[first + 1..last].iter().cloned())
700 .chain(adopted)
701 .collect(),
702 }))
703 }
704
513705 /// Rejects exactly the edits after which [`validate_nodes`] would reject the document, or
514706 /// whose column widths or text are invalid, looking only where the edit can conflict.
515707 pub(crate) fn validate_edit(&self, edit: &DocumentEdit) -> Result<(), EditError> {
......@@ -899,13 +1091,21 @@ mod tests {
8991091 assert_eq!(document.nodes()[1], original.nodes()[1]);
9001092 document.apply(undo).unwrap();
9011093 assert_eq!(document, original);
902 assert_eq!(
903 document.replace(
1094 let edit = document
1095 .replace(
9041096 position(0, 0)..position(4, 0),
905 vec![Paragraph::new(String::new(), Format::default())]
906 ),
907 Err(EditError::UnsupportedContent)
1097 vec![Paragraph::new(String::new(), Format::default())],
1098 )
1099 .unwrap();
1100 document.apply(edit).unwrap();
1101 assert_eq!(
1102 document
1103 .paragraphs()
1104 .map(Paragraph::text)
1105 .collect::<Vec<_>>(),
1106 ["after"]
9081107 );
1108 assert_eq!(document.nodes().len(), 1);
9091109 }
9101110
9111111 #[test]
......@@ -1221,7 +1421,7 @@ mod tests {
12211421 }
12221422
12231423 #[test]
1224 fn editing_nested_tagged_text_preserves_metadata_and_refuses_structural_changes() {
1424 fn editing_nested_tagged_text_preserves_metadata_through_structural_changes() {
12251425 let mut nodes = TextDocument::new(
12261426 ["parent", "a🌳e\u{301}z", "child"]
12271427 .into_iter()
......@@ -1284,19 +1484,51 @@ mod tests {
12841484 assert_eq!(document, edited);
12851485 }
12861486 }
1287 for (range, count) in [
1288 (position(1, 0)..position(1, 0), 2),
1289 (position(0, 6)..position(1, 0), 1),
1290 (position(1, 0)..position(2, 0), 1),
1291 ] {
1292 assert_eq!(
1293 original.replace(
1487 let structure = |range: Range<TextPosition>, count| {
1488 let mut document = original.clone();
1489 let edit = document
1490 .replace(
12941491 range,
1295 vec![Paragraph::new(String::new(), Format::default()); count]
1296 ),
1297 Err(EditError::UnsupportedContent)
1298 );
1299 }
1492 vec![Paragraph::new(String::new(), Format::default()); count],
1493 )
1494 .unwrap();
1495 let undo = document.apply(edit).unwrap();
1496 let edited = document.nodes().to_vec();
1497 document.apply(undo).unwrap();
1498 assert_eq!(document, original);
1499 edited
1500 };
1501 let [parent, item, child] = original.nodes() else {
1502 unreachable!()
1503 };
1504 // The new half takes the list, the children and their collapsed state, not the tags.
1505 let split = structure(position(1, 0)..position(1, 0), 2);
1506 assert_eq!(split[1].id, item.id);
1507 assert!(split[1].text().unwrap().text.text().is_empty() && !split[1].collapsed);
1508 assert_eq!(split[1].tags, item.tags);
1509 let tail = &split[2];
1510 assert_eq!(tail.text().unwrap().text, item.text().unwrap().text);
1511 assert_eq!((tail.parent, tail.level), (Some(parent.id), 2));
1512 assert_eq!(tail.lists, item.lists);
1513 assert!(tail.tags.is_empty() && tail.text().unwrap().tags.is_empty() && tail.collapsed);
1514 assert_eq!((split[3].parent, split[3].level), (Some(tail.id), 3));
1515 // The upper paragraph wins a join and adopts the lower one's children.
1516 let joined = structure(position(0, 6)..position(1, 0), 1);
1517 assert_eq!(joined[0].text().unwrap().text.text(), "parenta🌳e\u{301}z");
1518 assert_eq!((joined[0].id, joined[0].lists.len()), (parent.id, 0));
1519 assert_eq!(
1520 (joined[1].id, joined[1].parent, joined[1].level),
1521 (child.id, Some(parent.id), 2)
1522 );
1523 let joined = structure(position(1, 6)..position(2, 0), 1);
1524 assert_eq!(joined.len(), 2);
1525 assert_eq!(
1526 PageParagraph {
1527 content: item.content.clone(),
1528 ..joined[1].clone()
1529 },
1530 *item
1531 );
13001532 }
13011533
13021534 #[test]
......@@ -1365,7 +1597,7 @@ mod tests {
13651597 .unwrap();
13661598 for (original, regions) in [
13671599 (original, vec![0, 0, 0]),
1368 (table_document(), vec![0, 1, 1, 2, 3]),
1600 (table_document(), vec![0, 1, 1, 2, 0]),
13691601 ] {
13701602 let positions: Vec<_> = original
13711603 .paragraphs()
crates/canvas/src/editor.rs+348-99
......@@ -6,6 +6,7 @@ use crate::{
66};
77use draw::edit::{self, Movement, SelectionUnit};
88use onestore::ExGuid;
9use onestore::document::{Format, Kind};
910use onestore::page::text::{EditError, Paragraph};
1011use onestore::page::{Definition, Outline, Page, PageObject, PageParagraph, Title};
1112use parley::{
......@@ -20,6 +21,8 @@ use std::{
2021
2122pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;
2223
24#[cfg(test)]
25mod evidence;
2326mod format;
2427pub(crate) mod page;
2528mod table;
......@@ -126,22 +129,20 @@ pub struct TextOutline {
126129impl TextOutline {
127130 /// A single plain paragraph containing only ASCII spaces or no text.
128131 pub fn is_empty(&self) -> bool {
129 self.document.nodes().len() == 1
130 && self.document.validate_flat().is_ok()
131 && self
132 .document
133 .paragraphs()
134 .next()
135 .unwrap()
136 .text()
137 .bytes()
138 .all(|byte| byte == b' ')
132 let [node] = self.document.nodes() else {
133 return false;
134 };
135 node.lists.is_empty()
136 && node.tags.is_empty()
137 && !node.collapsed
138 && node.text().is_some_and(|text| {
139 text.tags.is_empty() && text.text.text().bytes().all(|byte| byte == b' ')
140 })
139141 }
140142
141143 /// Tests an outline-local point against the provisional paragraph band.
142144 pub fn contains_extension(&self, point: [f32; 2]) -> bool {
143145 !self.title
144 && self.document.validate_flat().is_ok()
145146 && (0.0..=self.shaped.size[0]).contains(&point[0])
146147 && point[1] > self.shaped.size[1]
147148 && point[1] <= self.shaped.size[1] + 27.0
......@@ -1148,7 +1149,11 @@ impl CanvasEditor {
11481149 let outline = self.active_outline();
11491150 let count = outline.document.nodes().len();
11501151 let height = outline.shaped.size[1];
1151 let node = self.blank_paragraph(&outline.document.nodes()[count - 1])?;
1152 let last = &outline.document.nodes()[count - 1];
1153 let node = PageParagraph {
1154 parent: last.parent,
1155 ..self.blank_paragraph(last)?
1156 };
11521157 self.apply(
11531158 engine,
11541159 DocumentEdit {
......@@ -1864,13 +1869,6 @@ impl CanvasEditor {
18641869 };
18651870 if target.is_none() && !extend && anchor == focus && !self.active_outline().title {
18661871 let outline = self.active_outline();
1867 crate::document::validate_flat(
1868 outline
1869 .document
1870 .nodes()
1871 .iter()
1872 .filter(|node| node.text().is_some()),
1873 )?;
18741872 let last = outline.document.nodes().len() - 1;
18751873 let up = matches!(movement, Movement::Up);
18761874 let base_node = &outline.document.nodes()[if up { 0 } else { last }];
......@@ -1964,7 +1962,10 @@ impl CanvasEditor {
19641962 columns: BTreeMap::new(),
19651963 container: None,
19661964 range: last + 1..last + 1,
1967 replacement: vec![node],
1965 replacement: vec![PageParagraph {
1966 parent: base_node.parent,
1967 ..node
1968 }],
19681969 };
19691970 let paragraph = outline.document.paragraphs().count();
19701971 let previous = if let Focus::Outline(index) = self.active {
......@@ -2223,12 +2224,127 @@ impl CanvasEditor {
22232224 self.replace(engine, replacement)
22242225 }
22252226
2227 /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style
2228 /// or list between the halves of the caret's paragraph (`evidence/structural-edits/xml/c7-*`).
2229 pub fn paste(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {
2230 let lines = text
2231 .split('\n')
2232 .map(|line| line.strip_suffix('\r').unwrap_or(line))
2233 .collect::<Vec<_>>();
2234 let last = lines[lines.len() - 1];
2235 if lines.len() == 1 {
2236 return self.insert(engine, last);
2237 }
2238 let [anchor, focus] = self.active_outline().selection.positions;
2239 let (start, end) = (anchor.min(focus), anchor.max(focus));
2240 let edge = Paragraph::new(String::new(), self.typing_format(start)?);
2241 let pasted = Format {
2242 font: Some("Calibri".into()),
2243 font_size: Some(11.0),
2244 ..Format::default()
2245 };
2246 let mut edit = self.active_outline().document.replace(
2247 start..end,
2248 std::iter::once(edge.clone())
2249 .chain(
2250 lines
2251 .iter()
2252 .map(|line| Paragraph::new((*line).to_owned(), pasted.clone())),
2253 )
2254 .chain([edge])
2255 .collect(),
2256 )?;
2257 for node in &mut edit.replacement[1..=lines.len()] {
2258 node.style = None;
2259 node.lists.clear();
2260 }
2261 let caret = TextPosition {
2262 paragraph: start.paragraph + lines.len(),
2263 offset: u32::try_from(last.encode_utf16().count())
2264 .map_err(|_| EditError::TextTooLong)?,
2265 };
2266 self.commit(
2267 engine,
2268 edit,
2269 Selection {
2270 positions: [caret; 2],
2271 affinities: [Affinity::Upstream; 2],
2272 },
2273 )
2274 }
2275
2276 /// Enter as OneNote 2010 does (`evidence/structural-edits`): the new paragraph takes the
2277 /// level, lists and character formatting at the caret but no note tags. At a paragraph's
2278 /// start its tags stay with its text below; an empty paragraph opens a plain one above and
2279 /// leaves the list it ends; a heading continues as body text.
2280 fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
2281 let [anchor, focus] = self.active_outline().selection.positions;
2282 let (start, end) = (anchor.min(focus), anchor.max(focus));
2283 let format = self.typing_format(start)?;
2284 let document = &self.active_outline().document;
2285 let mut edit =
2286 document.replace(start..end, vec![Paragraph::new(String::new(), format); 2])?;
2287 let nodes = document.container(edit.container)?;
2288 let next = nodes
2289 .get(crate::document::subtree_end(nodes, edit.range.start))
2290 .filter(|next| {
2291 let node = &nodes[edit.range.start];
2292 next.parent == node.parent && next.level == node.level
2293 });
2294 let [head, tail, ..] = &mut edit.replacement[..] else {
2295 unreachable!("a split holds both halves")
2296 };
2297 let empty = |node: &PageParagraph| node.text().unwrap().text.text().is_empty();
2298 if start.offset == 0 {
2299 tail.tags = std::mem::take(&mut head.tags);
2300 tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags);
2301 }
2302 let style = |id: Option<ExGuid>| match &self.definitions.get(&id?)?.kind {
2303 Kind::Style { name } => name.as_deref(),
2304 _ => None,
2305 };
2306 if empty(head) && empty(tail) {
2307 head.lists.clear();
2308 if next.is_none_or(|next| next.lists.is_empty()) {
2309 tail.lists.clear();
2310 }
2311 } else if empty(tail)
2312 && matches!(
2313 style(head.style),
2314 Some("h1" | "h2" | "h3" | "h4" | "h5" | "h6")
2315 )
2316 {
2317 tail.style = self
2318 .definitions
2319 .keys()
2320 .copied()
2321 .find(|id| style(Some(*id)) == Some("p"));
2322 tail.text_mut().unwrap().text =
2323 Paragraph::new(String::new(), self.style_format(tail.style)?);
2324 }
2325 let caret = TextPosition {
2326 paragraph: start.paragraph + 1,
2327 offset: 0,
2328 };
2329 self.commit(
2330 engine,
2331 edit,
2332 Selection {
2333 positions: [caret; 2],
2334 affinities: [Affinity::Upstream; 2],
2335 },
2336 )
2337 }
2338
2339 /// Tab or Shift+Tab at the start of the selected paragraphs; see [`crate::document::indent`].
2340 /// A default bullet or number steps to the style OneNote gives its new depth.
22262341 pub fn indent(&mut self, engine: &mut TextEngine, outdent: bool) -> Result<bool, EditorError> {
22272342 let outline = self.active_outline();
22282343 if outline.title {
22292344 return Ok(false);
22302345 }
2231 let [anchor, focus] = outline.selection.positions;
2346 let selection = outline.selection;
2347 let [anchor, focus] = selection.positions;
22322348 let start = anchor.min(focus);
22332349 let end = anchor.max(focus);
22342350 let (container, local_start, _) = outline
......@@ -2245,96 +2361,111 @@ impl CanvasEditor {
22452361 let range = local_start
22462362 ..local_end + usize::from(end.offset != 0 || start.paragraph == end.paragraph);
22472363 let nodes = outline.document.container(container)?;
2248 // Lists and tags move with their paragraphs; parent links and collapsed children would not.
2249 if nodes.iter().enumerate().any(|(index, node)| {
2250 (range.contains(&index) || node.text().is_some())
2251 && (node.parent.is_some() || node.collapsed)
2252 }) {
2253 return Err(EditError::UnsupportedContent.into());
2254 }
2255 let nodes = &nodes[range.clone()];
2256 if outdent && nodes.iter().all(|node| node.level == 1) {
2364 let Some(mut edit) = crate::document::indent(nodes, container, range, outdent) else {
22572365 return Ok(false);
2258 }
2259 let replacement = nodes
2366 };
2367 let levels = nodes[edit.range.clone()]
22602368 .iter()
2261 .map(|node| {
2262 let mut node = node.clone();
2263 node.level = if outdent {
2264 node.level.saturating_sub(1).max(1)
2265 } else {
2266 node.level
2267 .checked_add(1)
2268 .ok_or(EditError::InvalidStructure)?
2369 .map(|node| node.level)
2370 .collect::<Vec<_>>();
2371 for (node, level) in edit.replacement.iter_mut().zip(levels) {
2372 if node.level == level {
2373 continue;
2374 }
2375 for list in &mut node.lists {
2376 let Some(definition) = self
2377 .definitions
2378 .get(list)
2379 .and_then(|definition| format::nested_list(definition, node.level > level))
2380 else {
2381 continue;
22692382 };
2270 Ok(node)
2271 })
2272 .collect::<Result<Vec<_>, EditError>>()?;
2273 self.commit(
2274 engine,
2275 DocumentEdit {
2276 columns: BTreeMap::new(),
2277 container,
2278 range,
2279 replacement,
2280 },
2281 outline.selection,
2282 )?;
2383 *list = onestore::page::text::new_id()?;
2384 self.definitions.insert(*list, definition);
2385 }
2386 }
2387 self.commit(engine, edit, selection)?;
22832388 Ok(true)
22842389 }
22852390
2391 /// Deletes the selection or the character beside the caret. Backspace at a paragraph's start
2392 /// first removes its list, then outdents it, then joins it to the paragraph above, whose
2393 /// properties win; Delete at its end joins the paragraph below (`evidence/structural-edits/
2394 /// xml/c4-*`, `c5b-*`). Joins pass over a collapsed paragraph's hidden children.
22862395 pub fn delete(&mut self, engine: &mut TextEngine, backward: bool) -> Result<bool, EditorError> {
2287 let [anchor, focus] = self.active_outline().selection.positions;
2396 let selection = self.active_outline().selection;
2397 let [anchor, focus] = selection.positions;
22882398 let mut range = anchor.min(focus)..anchor.max(focus);
22892399 if range.is_empty() {
2290 let paragraph = self.active_outline().paragraph_layout(focus.paragraph)?;
2291 let cursor =
2292 paragraph.cursor(focus.offset, self.active_outline().selection.affinities[1])?;
2293 if let Some(cluster) =
2400 let outline = self.active_outline();
2401 let paragraph = outline.paragraph_layout(focus.paragraph)?;
2402 let cursor = paragraph.cursor(focus.offset, selection.affinities[1])?;
2403 let Some(cluster) =
22942404 cursor.logical_clusters(&paragraph.text.shaped)[usize::from(!backward)]
2295 {
2296 let visible = paragraph.projection.text();
2297 let bytes = cluster.text_range();
2298 range.start.offset = paragraph.projection.source_offset(
2299 visible.utf16_offset(bytes.start)?,
2300 onestore::page::text::Affinity::Downstream,
2301 )?;
2302 range.end.offset = paragraph.projection.source_offset(
2303 visible.utf16_offset(bytes.end)?,
2304 onestore::page::text::Affinity::Upstream,
2305 )?;
2306 } else {
2307 let document = &self.active_outline().document;
2405 else {
2406 let (container, local, node) = outline
2407 .document
2408 .leaf(focus.paragraph)
2409 .ok_or(EditError::InvalidRange)?;
2410 if backward && !node.lists.is_empty() {
2411 let item = PageParagraph {
2412 lists: Vec::new(),
2413 ..node.clone()
2414 };
2415 let edit = DocumentEdit {
2416 columns: BTreeMap::new(),
2417 container,
2418 range: local..local + 1,
2419 replacement: vec![item],
2420 };
2421 self.commit(engine, edit, selection)?;
2422 return Ok(true);
2423 }
2424 if backward && node.level > 1 {
2425 return self.indent(engine, true);
2426 }
2427 let visible = outline.visible_index(focus.paragraph)?;
23082428 let Some(neighbor) = (if backward {
2309 focus.paragraph.checked_sub(1)
2429 visible.checked_sub(1)
23102430 } else {
2311 focus.paragraph.checked_add(1)
2431 Some(visible + 1).filter(|next| *next < outline.shaped.paragraphs.len())
23122432 }) else {
23132433 return Ok(false);
23142434 };
2315 let (container, local, current) = document
2316 .leaf(focus.paragraph)
2317 .ok_or(EditError::InvalidRange)?;
2318 let Some((next_container, next_local, next)) = document.leaf(neighbor) else {
2319 return Ok(false);
2435 let neighbor = outline.source_index(neighbor);
2436 let [upper, lower] = if backward {
2437 [neighbor, focus.paragraph]
2438 } else {
2439 [focus.paragraph, neighbor]
23202440 };
2321 if container != next_container || local.abs_diff(next_local) != 1 {
2441 let Some(edit) = outline.document.join(upper, lower)? else {
23222442 return Ok(false);
2323 }
2324 let (previous, start, end) = if backward {
2325 (next, neighbor, focus.paragraph)
2326 } else {
2327 (current, focus.paragraph, neighbor)
23282443 };
2329 let text = &previous.text().unwrap().text;
2330 range = TextPosition {
2331 paragraph: start,
2444 let text = outline.document.paragraph(upper).unwrap();
2445 let caret = TextPosition {
2446 paragraph: upper,
23322447 offset: text.utf16_offset(text.text().len())?,
2333 }..TextPosition {
2334 paragraph: end,
2335 offset: 0,
23362448 };
2337 }
2449 self.commit(
2450 engine,
2451 edit,
2452 Selection {
2453 positions: [caret; 2],
2454 affinities: [Affinity::Upstream; 2],
2455 },
2456 )?;
2457 return Ok(true);
2458 };
2459 let visible = paragraph.projection.text();
2460 let bytes = cluster.text_range();
2461 range.start.offset = paragraph.projection.source_offset(
2462 visible.utf16_offset(bytes.start)?,
2463 onestore::page::text::Affinity::Downstream,
2464 )?;
2465 range.end.offset = paragraph.projection.source_offset(
2466 visible.utf16_offset(bytes.end)?,
2467 onestore::page::text::Affinity::Upstream,
2468 )?;
23382469 }
23392470 self.delete_range(engine, range)
23402471 }
......@@ -2748,10 +2879,11 @@ impl CanvasEditor {
27482879 .split('\n')
27492880 .map(|part| Paragraph::new(part.to_owned(), format.clone()))
27502881 .collect();
2751 let edit = self
2882 let mut edit = self
27522883 .active_outline()
27532884 .document
27542885 .replace(range.clone(), replacement)?;
2886 self.own_lists(&mut edit)?;
27552887 let inverse = self.apply(
27562888 engine,
27572889 edit,
......@@ -2764,6 +2896,12 @@ impl CanvasEditor {
27642896 )?;
27652897 let original = if let Some(composition) = self.composition.take() {
27662898 let mut original = composition.original;
2899 // Nodes this edit reaches past what the composition changed are still as they were.
2900 let changed = original.edit.range.len();
2901 original
2902 .edit
2903 .replacement
2904 .extend(inverse.edit.replacement.iter().skip(changed).cloned());
27672905 original.edit.range = inverse.edit.range;
27682906 for (id, widths) in inverse.edit.columns {
27692907 original.edit.columns.entry(id).or_insert(widths);
......@@ -2832,14 +2970,40 @@ impl CanvasEditor {
28322970 fn commit(
28332971 &mut self,
28342972 engine: &mut TextEngine,
2835 edit: DocumentEdit,
2973 mut edit: DocumentEdit,
28362974 selection: Selection,
28372975 ) -> Result<(), EditorError> {
2976 self.own_lists(&mut edit)?;
28382977 let inverse = self.apply(engine, edit, selection, true, None)?;
28392978 self.record_change(inverse);
28402979 Ok(())
28412980 }
28422981
2982 /// Gives each paragraph an edit adds its own copy of the lists it carries, as OneNote keeps
2983 /// a list node per paragraph.
2984 fn own_lists(&mut self, edit: &mut DocumentEdit) -> Result<(), EditError> {
2985 let existing = self.active_outline().document.container(edit.container)?
2986 [edit.range.clone()]
2987 .iter()
2988 .map(|node| node.id)
2989 .collect::<BTreeSet<_>>();
2990 for node in &mut edit.replacement {
2991 if existing.contains(&node.id) {
2992 continue;
2993 }
2994 for list in &mut node.lists {
2995 let definition = self
2996 .definitions
2997 .get(list)
2998 .ok_or(EditError::InvalidStructure)?
2999 .clone();
3000 *list = onestore::page::text::new_id()?;
3001 self.definitions.insert(*list, definition);
3002 }
3003 }
3004 Ok(())
3005 }
3006
28433007 fn record_change(&mut self, change: TextChange) {
28443008 self.pending = None;
28453009 if let Focus::Draft { index, .. } = self.active {
......@@ -5553,6 +5717,9 @@ mod tests {
55535717 .collect::<Vec<_>>(),
55545718 [2, 2, 1]
55555719 );
5720 // Backspace outdents an indented paragraph before joining it (`c4-child-1.xml`).
5721 editor.delete(&mut engine, true).unwrap();
5722 assert_eq!(editor.active_outline().document.nodes()[1].level, 1);
55565723 editor.delete(&mut engine, true).unwrap();
55575724 assert_eq!(
55585725 editor.active_outline().document.nodes()[0]
......@@ -5562,9 +5729,9 @@ mod tests {
55625729 .text(),
55635730 "alpha beta gamma delta"
55645731 );
5565 editor.undo(&mut engine).unwrap();
5566 editor.undo(&mut engine).unwrap();
5567 editor.undo(&mut engine).unwrap();
5732 for _ in 0..4 {
5733 editor.undo(&mut engine).unwrap();
5734 }
55685735 assert_eq!(editor.active_outline().document, source);
55695736 assert_eq!(editor.selection(), selection);
55705737 assert!(!editor.indent(&mut engine, true).unwrap());
......@@ -6505,7 +6672,16 @@ mod tests {
65056672 let edited = layout_snapshot(&editor.active_outline().shaped);
65066673 let before = editor.active_outline().document.clone();
65076674 let selection = editor.selection();
6508 assert!(editor.insert(&mut engine, "\n").is_err());
6675 editor.enter(&mut engine, false).unwrap();
6676 let split = &editor.active_outline().document.nodes()[2];
6677 assert_eq!((split.parent, split.level), (Some(nodes[0].id), 2));
6678 assert_ne!(split.lists, [marker]);
6679 assert_eq!(
6680 editor.definitions[&split.lists[0]],
6681 editor.definitions[&marker]
6682 );
6683 editor.undo(&mut engine).unwrap();
6684 assert_eq!(editor.active_outline().document, before);
65096685 assert!(editor.resize(&mut engine, 20.0).is_err());
65106686 assert_eq!(editor.active_outline().document, before);
65116687 assert_eq!(editor.selection(), selection);
......@@ -6555,6 +6731,75 @@ mod tests {
65556731 assert!(!editor.selection_rects().unwrap().is_empty());
65566732 }
65576733
6734 #[test]
6735 fn joins_pass_over_hidden_children_and_a_split_hands_them_on_still_collapsed() {
6736 let mut engine = TextEngine::default();
6737 let mut nodes = TextDocument::new(
6738 ["Collapsed", "Hidden", "After"]
6739 .into_iter()
6740 .map(|text| Paragraph::new(text.into(), Format::default()))
6741 .collect(),
6742 )
6743 .unwrap()
6744 .nodes()
6745 .to_vec();
6746 nodes[0].collapsed = true;
6747 nodes[1].parent = Some(nodes[0].id);
6748 nodes[1].level = 2;
6749 let outline = Outline {
6750 paragraphs: nodes.clone(),
6751 ..TextOutline::new(
6752 &mut engine,
6753 TextDocument::from_nodes(nodes.clone()).unwrap(),
6754 300.0,
6755 [0.0; 2],
6756 )
6757 .unwrap()
6758 .snapshot()
6759 };
6760 let mut editor =
6761 CanvasEditor::from_outlines(&mut engine, vec![outline], BTreeMap::new()).unwrap();
6762 let texts = |editor: &CanvasEditor| {
6763 editor
6764 .active_outline()
6765 .document
6766 .paragraphs()
6767 .map(|text| text.text().to_owned())
6768 .collect::<Vec<_>>()
6769 };
6770 let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into();
6771 editor.select(at(2, 0)).unwrap();
6772 assert!(editor.delete(&mut engine, true).unwrap());
6773 assert_eq!(texts(&editor), ["CollapsedAfter", "Hidden"]);
6774 assert_eq!(
6775 editor.selection().positions,
6776 [TextPosition {
6777 paragraph: 0,
6778 offset: 9
6779 }; 2]
6780 );
6781 editor.undo(&mut engine).unwrap();
6782 editor.select(at(0, 9)).unwrap();
6783 assert!(editor.delete(&mut engine, false).unwrap());
6784 assert_eq!(texts(&editor), ["CollapsedAfter", "Hidden"]);
6785 editor.undo(&mut engine).unwrap();
6786 editor.select(at(0, 9)).unwrap();
6787 editor.enter(&mut engine, false).unwrap();
6788 let split = editor.active_outline().document.nodes();
6789 assert!(!split[0].collapsed && split[1].collapsed);
6790 assert_eq!(split[2].parent, Some(split[1].id));
6791 assert_eq!(
6792 editor
6793 .active_outline()
6794 .layouts()
6795 .map(|(index, _)| index)
6796 .collect::<Vec<_>>(),
6797 [0, 1, 3]
6798 );
6799 editor.undo(&mut engine).unwrap();
6800 assert_eq!(editor.active_outline().document.nodes(), nodes);
6801 }
6802
65586803 #[test]
65596804 #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]
65606805 fn imported_editor_reflows_and_restores_native_outline_geometry() {
......@@ -7934,9 +8179,13 @@ mod tests {
79348179 .outlines
79358180 .iter()
79368181 .find(|outline| {
8182 let [node] = outline.document.nodes() else {
8183 return false;
8184 };
79378185 !outline.title
7938 && outline.document.nodes().len() == 1
7939 && outline.document.validate_flat().is_ok()
8186 && node.lists.is_empty()
8187 && node.tags.is_empty()
8188 && node.text().is_some_and(|text| text.tags.is_empty())
79408189 })
79418190 .unwrap()
79428191 .id;
crates/canvas/src/editor/evidence.rs created+765
......@@ -0,0 +1,765 @@
1//! OneNote 2010's Enter, Backspace, Delete, Tab and paste replayed against the page XML it
2//! reported after each keystroke (`evidence/structural-edits`): each outline is built from, and
3//! compared with, the one-line-per-paragraph summaries in `summaries/`, whose `L` counts
4//! paragraph nesting.
5
6use super::format::{NoteTag, list_definition};
7use super::*;
8use onestore::document::Tag;
9use onestore::page::ParagraphContent;
10use onestore::page::text::new_id;
11
12fn summary(name: &str) -> String {
13 std::fs::read_to_string(format!(
14 "{}/../../evidence/structural-edits/summaries/{name}.txt",
15 env!("CARGO_MANIFEST_DIR")
16 ))
17 .unwrap()
18 .replace("\r\n", "\n")
19}
20
21fn calibri(size: f32, bold: bool) -> Format {
22 Format {
23 font: Some("Calibri".into()),
24 font_size: Some(size),
25 bold: Some(bold),
26 ..Format::default()
27 }
28}
29
30/// The quick styles a summary's `qs0` and `qs1` name.
31const STYLES: [&str; 2] = ["p", "h1"];
32
33/// An editor holding one outline shaped as a summary describes it.
34fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor {
35 let mut definitions = BTreeMap::new();
36 let mut ids = BTreeMap::new();
37 let mut define = |key: String, definition: Definition| {
38 *ids.entry(key).or_insert_with(|| {
39 let id = new_id().unwrap();
40 definitions.insert(id, definition);
41 id
42 })
43 };
44 let styles = STYLES.map(|name| {
45 define(
46 name.into(),
47 Definition {
48 kind: Kind::Style {
49 name: Some(name.into()),
50 },
51 format: calibri(if name == "p" { 11.0 } else { 16.0 }, name != "p"),
52 },
53 )
54 });
55 let mut nodes: Vec<PageParagraph> = Vec::new();
56 for line in summary.lines() {
57 let line = line.trim_start().strip_prefix('L').unwrap();
58 let (level, line) = line.split_once(" [").unwrap();
59 let level = level.parse::<u32>().unwrap();
60 let (tags, line) = line.split_once("] [").unwrap();
61 let (list, line) = line.split_once("] ").unwrap();
62 let (style, text) = line.split_once(' ').unwrap();
63 let style = STYLES
64 .iter()
65 .position(|name| style == format!("qs{}", usize::from(*name == "h1")))
66 .map(|index| styles[index]);
67 let format = calibri(11.0, false);
68 let mut node =
69 crate::document::node(Paragraph::new(text.into(), format.clone()), format.clone())
70 .unwrap();
71 node.level = level;
72 node.style = style;
73 node.parent = nodes
74 .iter()
75 .rev()
76 .find(|parent| parent.level + 1 == level)
77 .map(|parent| parent.id);
78 if !list.is_empty() {
79 let definition = list_definition(list.starts_with("num"), &format);
80 node.lists = vec![define(format!("list {}", nodes.len()), definition)];
81 }
82 for tag in tags.split(',').filter(|tag| !tag.is_empty()) {
83 let (label, completed) = match tag.strip_suffix("[x]") {
84 Some(label) => (label, true),
85 None => (tag, false),
86 };
87 let tag = NoteTag::ALL
88 .into_iter()
89 .find(|tag| {
90 matches!(&tag.definition().kind, Kind::TagDefinition { label: Some(name), .. } if name == label)
91 })
92 .unwrap();
93 let definition = define(label.into(), tag.definition());
94 node.text_mut().unwrap().tags.push(Tag {
95 definition: Some(definition),
96 action_type: None,
97 status: u16::from(completed),
98 created: Some(1),
99 completed: Some(u32::from(completed)),
100 start: None,
101 due: None,
102 task_id: None,
103 extra_set: 0,
104 });
105 }
106 nodes.push(node);
107 }
108 let outline = Outline {
109 id: new_id().unwrap(),
110 title: false,
111 min_width: None,
112 layout: onestore::document::Layout {
113 max_width: Some(400.0),
114 ..Default::default()
115 },
116 indents: vec![18.0, 0.0, 27.0, 27.0],
117 paragraphs: nodes,
118 unsupported: Vec::new(),
119 };
120 CanvasEditor::from_outlines(engine, vec![outline], definitions).unwrap()
121}
122
123/// The outline in the summaries' form.
124fn render(editor: &CanvasEditor) -> String {
125 let outline = editor.active_outline();
126 let mut depths = BTreeMap::new();
127 let mut lines = Vec::new();
128 for node in outline.document.nodes() {
129 let depth = node.parent.map_or(1, |parent| depths[&parent] + 1);
130 depths.insert(node.id, depth);
131 let tags = node
132 .tags
133 .iter()
134 .chain(&node.text().unwrap().tags)
135 .map(|tag| {
136 let kind = &editor.definitions[&tag.definition.unwrap()].kind;
137 let Kind::TagDefinition { label, .. } = kind else {
138 panic!("{kind:?}")
139 };
140 let checked = if tag.status & 1 == 1 { "[x]" } else { "" };
141 (
142 NoteTag::ALL
143 .iter()
144 .position(|known| known.definition().kind == *kind),
145 format!("{}{checked}", label.as_deref().unwrap()),
146 )
147 })
148 .collect::<BTreeMap<_, _>>();
149 let list = match node.lists.first().map(|id| &editor.definitions[id].kind) {
150 None => String::new(),
151 Some(Kind::List {
152 bullet: Some(_), ..
153 }) => "bullet".into(),
154 Some(Kind::List {
155 format: Some(format),
156 ..
157 }) => {
158 let (_, rest) = format.split_once('\u{fffd}').unwrap();
159 let mut rest = rest.chars();
160 let number = outline
161 .shaped
162 .paragraphs
163 .iter()
164 .find(|paragraph| paragraph.id == node.id)
165 .and_then(|paragraph| paragraph.number)
166 .unwrap()
167 .0;
168 let numeral = crate::outline::numeral(rest.next(), number).unwrap();
169 format!("num:{numeral}{}", rest.as_str())
170 }
171 Some(kind) => panic!("{kind:?}"),
172 };
173 let style = node
174 .style
175 .map(|style| match &editor.definitions[&style].kind {
176 Kind::Style { name } => {
177 format!("qs{}", usize::from(name.as_deref() == Some("h1")))
178 }
179 kind => panic!("{kind:?}"),
180 })
181 .unwrap_or_default();
182 lines.push(format!(
183 "{}L{depth} [{}] [{list}] {style} {}",
184 " ".repeat(depth as usize),
185 tags.into_values().collect::<Vec<_>>().join(","),
186 node.text().unwrap().text.text()
187 ));
188 }
189 lines.join("\n").trim_end().to_owned()
190}
191
192#[derive(Clone, Copy)]
193enum Offset {
194 Start,
195 End,
196 /// Characters before the end.
197 Back(u32),
198 /// Characters after the start.
199 Forward(u32),
200}
201
202#[derive(Clone, Copy)]
203enum Key {
204 Enter,
205 Backspace,
206 Delete,
207 Tab,
208 ShiftTab,
209 Type(&'static str),
210 Paste(&'static str),
211 Bullets,
212}
213
214/// Puts the caret at `offset` in paragraph `paragraph`.
215fn place(editor: &mut CanvasEditor, (paragraph, offset): (usize, Offset)) {
216 let text = editor
217 .active_outline()
218 .document
219 .paragraph(paragraph)
220 .unwrap();
221 let length = text.text().encode_utf16().count() as u32;
222 let offset = match offset {
223 Offset::Start => 0,
224 Offset::End => length,
225 Offset::Back(count) => length - count,
226 Offset::Forward(count) => count,
227 };
228 editor
229 .select([TextPosition { paragraph, offset }; 2].into())
230 .unwrap();
231}
232
233fn press(editor: &mut CanvasEditor, engine: &mut TextEngine, key: Key) {
234 match key {
235 Key::Enter => editor.enter(engine, false).unwrap(),
236 Key::Backspace => assert!(editor.delete(engine, true).unwrap()),
237 Key::Delete => assert!(editor.delete(engine, false).unwrap()),
238 Key::Tab => editor.tab(engine, false).unwrap(),
239 Key::ShiftTab => editor.tab(engine, true).unwrap(),
240 Key::Type(text) => editor.insert(engine, text).unwrap(),
241 Key::Paste(text) => editor.paste(engine, text).unwrap(),
242 Key::Bullets => editor.format(engine, Formatting::Bullets).unwrap(),
243 }
244}
245
246/// Replays `steps` from `caret` in `before`, comparing the outline after each key with the
247/// summary it names; returns the editor for further checks.
248fn replay(
249 engine: &mut TextEngine,
250 before: &str,
251 caret: (usize, Offset),
252 steps: &[(Key, &str)],
253) -> CanvasEditor {
254 let mut editor = open(engine, before);
255 place(&mut editor, caret);
256 let original = editor.active_outline().document.clone();
257 for (key, after) in steps {
258 press(&mut editor, engine, *key);
259 assert_eq!(render(&editor), summary(after).trim_end(), "{after}");
260 let outline = editor.active_outline();
261 let fresh = OutlineLayout::flow(
262 outline.document.nodes().iter(),
263 &outline.indents,
264 outline.wrap_width(),
265 true,
266 0,
267 None,
268 &mut |node, previous, width, indents| {
269 ParagraphLayout::shape(engine, node, previous, width, indents, &editor.definitions)
270 },
271 )
272 .unwrap();
273 let numbers = |layout: &OutlineLayout| {
274 layout
275 .paragraphs
276 .iter()
277 .map(|paragraph| paragraph.number)
278 .collect::<Vec<_>>()
279 };
280 assert_eq!(numbers(&outline.shaped), numbers(&fresh), "{after}");
281 }
282 let edited = editor.active_outline().document.clone();
283 while editor.undo(engine).unwrap() {}
284 assert_eq!(editor.active_outline().document, original);
285 while editor.redo(engine).unwrap() {}
286 assert_eq!(editor.active_outline().document, edited);
287 editor
288}
289
290/// The run that recorded Enter at the end of a paragraph of `kind`.
291fn first_run(kind: &str) -> &'static str {
292 match kind {
293 "num" | "child" | "tagbullet" => "c1b",
294 _ => "c1",
295 }
296}
297
298/// The setup every single-paragraph case starts from: `Above`, then `Target text` of `kind`.
299fn target(kind: &str) -> String {
300 summary(&format!("{}-{kind}-0", first_run(kind)))
301}
302
303const KINDS: [&str; 9] = [
304 "todo",
305 "done",
306 "imp",
307 "q",
308 "rem",
309 "bullet",
310 "num",
311 "child",
312 "tagbullet",
313];
314
315#[test]
316fn enter_carries_level_lists_and_children_but_never_tags() {
317 let mut engine = TextEngine::default();
318 for kind in KINDS {
319 let prefix = first_run(kind);
320 replay(
321 &mut engine,
322 &target(kind),
323 (1, Offset::End),
324 &[
325 (Key::Enter, &format!("{prefix}-{kind}-1")),
326 (Key::Type("New"), &format!("{prefix}-{kind}-2")),
327 ],
328 );
329 replay(
330 &mut engine,
331 &target(kind),
332 (1, Offset::Back(4)),
333 &[
334 (Key::Enter, &format!("c2-{kind}-1")),
335 (Key::Type("X"), &format!("c2-{kind}-2")),
336 ],
337 );
338 replay(
339 &mut engine,
340 &target(kind),
341 (1, Offset::Start),
342 &[
343 (Key::Enter, &format!("c2s-{kind}-1")),
344 (Key::Type("X"), &format!("c2s-{kind}-2")),
345 ],
346 );
347 replay(
348 &mut engine,
349 &summary(&format!("c3-{kind}-0")),
350 (2, Offset::Start),
351 &[
352 (Key::Enter, &format!("c3-{kind}-1")),
353 (Key::Enter, &format!("c3-{kind}-2")),
354 (Key::Type("New"), &format!("c3-{kind}-3")),
355 ],
356 );
357 }
358 replay(
359 &mut engine,
360 &summary("pbe-0"),
361 (2, Offset::Start),
362 &[(Key::Enter, "pbe-1")],
363 );
364 replay(
365 &mut engine,
366 &summary("c8-enter-empty-0"),
367 (1, Offset::End),
368 &[
369 (Key::Enter, "c8-enter-empty-1"),
370 (Key::Enter, "c8-enter-empty-2"),
371 ],
372 );
373 replay(
374 &mut engine,
375 &summary("c8-split-0"),
376 (2, Offset::Forward(1)),
377 &[(Key::Enter, "c8-split-1")],
378 );
379 replay(
380 &mut engine,
381 &summary("c10-enter-end-0"),
382 (1, Offset::End),
383 &[(Key::Enter, "c10-enter-end-1")],
384 );
385}
386
387#[test]
388fn backspace_removes_the_list_then_outdents_then_joins_and_the_upper_paragraph_wins() {
389 let mut engine = TextEngine::default();
390 for kind in KINDS {
391 let steps = ["-1", "-2", "-3"].map(|step| format!("c4-{kind}{step}"));
392 replay(
393 &mut engine,
394 &summary(&format!("c4-{kind}-0")),
395 (2, Offset::Start),
396 &steps.each_ref().map(|step| (Key::Backspace, step.as_str())),
397 );
398 }
399 for pair in [
400 "imp-todo",
401 "plain-todo",
402 "todo-plain",
403 "todo-done",
404 "done-todo",
405 "q-rem",
406 ] {
407 replay(
408 &mut engine,
409 &summary(&format!("c4m-{pair}-0")),
410 (1, Offset::Start),
411 &[(Key::Backspace, &format!("c4m-{pair}-1"))],
412 );
413 }
414 replay(
415 &mut engine,
416 &summary("c4m-emptybullet-0"),
417 (2, Offset::Start),
418 &[
419 (Key::Backspace, "c4m-emptybullet-1"),
420 (Key::Backspace, "c4m-emptybullet-2"),
421 (Key::Backspace, "c4m-emptybullet-3"),
422 ],
423 );
424 replay(
425 &mut engine,
426 &summary("c4m-emptytag-0"),
427 (1, Offset::Start),
428 &[
429 (Key::Backspace, "c4m-emptytag-1"),
430 (Key::Backspace, "c4m-emptytag-2"),
431 ],
432 );
433 replay(
434 &mut engine,
435 &summary("c8-bs-0"),
436 (2, Offset::Start),
437 &[(Key::Backspace, "c8-bs-1"), (Key::Backspace, "c8-bs-2")],
438 );
439 replay(
440 &mut engine,
441 &summary("c10-bs-parent-0"),
442 (1, Offset::Start),
443 &[(Key::Backspace, "c10-bs-parent-1")],
444 );
445}
446
447#[test]
448fn delete_at_the_end_joins_the_next_paragraph_into_this_one() {
449 let mut engine = TextEngine::default();
450 for (pair, paragraph) in [
451 ("imp-todo", 0),
452 ("plain-todo", 0),
453 ("todo-plain", 0),
454 ("done-todo", 0),
455 ("plain-bullet", 0),
456 ("plain-num", 0),
457 ("bullet-plain", 1),
458 ("bullet-bullet", 1),
459 ] {
460 replay(
461 &mut engine,
462 &summary(&format!("c5b-{pair}-0")),
463 (paragraph, Offset::End),
464 &[(Key::Delete, &format!("c5b-{pair}-1"))],
465 );
466 }
467 replay(
468 &mut engine,
469 &summary("c8-del-0"),
470 (1, Offset::End),
471 &[(Key::Delete, "c8-del-1")],
472 );
473 for (name, paragraph) in [("del-alpha", 0), ("del-parent", 1)] {
474 replay(
475 &mut engine,
476 &summary(&format!("c10-{name}-0")),
477 (paragraph, Offset::End),
478 &[(Key::Delete, &format!("c10-{name}-1"))],
479 );
480 }
481}
482
483/// The list definition of the outline's paragraph `index`.
484fn list(editor: &CanvasEditor, index: usize) -> &Kind<'static> {
485 let node = &editor.active_outline().document.nodes()[index];
486 &editor.definitions[&node.lists[0]].kind
487}
488
489#[test]
490fn tab_nests_under_the_previous_sibling_and_shift_tab_adopts_the_siblings_after() {
491 let mut engine = TextEngine::default();
492 for kind in ["plain", "todo", "bullet", "num", "child", "tagbullet"] {
493 let editor = replay(
494 &mut engine,
495 &summary(&format!("c6-{kind}-0")),
496 (1, Offset::Start),
497 &[(Key::Tab, &format!("c6-{kind}-tab"))],
498 );
499 // Without a previous sibling OneNote indents within the group (`<OEChildren indent="2">`
500 // in `c6-{bullet,num,child,tagbullet}-tab.xml`), stepping bullets to ○ and numbers to a.
501 let first_child = matches!(kind, "bullet" | "num" | "child" | "tagbullet");
502 let node = &editor.active_outline().document.nodes()[1];
503 assert_eq!(node.level, if first_child { 3 } else { 2 }, "{kind}");
504 if matches!(kind, "bullet" | "tagbullet") {
505 assert!(matches!(
506 list(&editor, 1),
507 Kind::List { format: Some(glyph), bullet: Some(4), .. } if glyph == "\u{25cb}"
508 ));
509 }
510 replay(
511 &mut engine,
512 &summary(&format!("c6-{kind}-0")),
513 (1, Offset::Start),
514 &[
515 (Key::Tab, &format!("c6-{kind}-tab")),
516 (Key::ShiftTab, &format!("c6-{kind}-stab1")),
517 (Key::ShiftTab, &format!("c6-{kind}-stab2")),
518 ],
519 );
520 }
521 let first = replay(
522 &mut engine,
523 " L1 [] [] qs0 Target text",
524 (0, Offset::Start),
525 &[(Key::Tab, "c6-first-tab")],
526 );
527 assert_eq!(
528 (
529 first.active_outline().document.nodes()[0].level,
530 first.active_outline().document.nodes()[0].parent
531 ),
532 (2, None)
533 );
534 replay(
535 &mut engine,
536 &summary("c8-stab-0"),
537 (2, Offset::Start),
538 &[(Key::ShiftTab, "c8-stab-1")],
539 );
540 replay(
541 &mut engine,
542 &summary("c8-tab-0"),
543 (2, Offset::Start),
544 &[(Key::Tab, "c8-tab-1"), (Key::ShiftTab, "c8-tab-2")],
545 );
546 replay(
547 &mut engine,
548 &summary("c10-stab-child-0"),
549 (2, Offset::Start),
550 &[(Key::ShiftTab, "c10-stab-child-1")],
551 );
552}
553
554#[test]
555fn pasted_lines_are_plain_paragraphs_between_the_halves() {
556 let mut engine = TextEngine::default();
557 for kind in ["todo", "done", "bullet", "num", "tagbullet", "child"] {
558 // The native run's clipboard held a stray backquote for the numbered case.
559 let text = if kind == "num" {
560 "Line one\r\nLine two`"
561 } else {
562 "Line one\r\nLine two"
563 };
564 let editor = replay(
565 &mut engine,
566 &target(kind),
567 (1, Offset::Back(4)),
568 &[(Key::Paste(text), &format!("c7-{kind}-1"))],
569 );
570 let document = &editor.active_outline().document;
571 let format = Format {
572 bold: None,
573 ..calibri(11.0, false)
574 };
575 assert_eq!(document.paragraph(2).unwrap().spans()[0].format, format);
576 let last = document.paragraph(3).unwrap().text();
577 assert_eq!(
578 editor.selection().positions[1],
579 TextPosition {
580 paragraph: 3,
581 offset: last.len() as u32
582 }
583 );
584 }
585 replay(
586 &mut engine,
587 " L1 [] [] qs0 Above\n L2 [] [num:1.] qs0 First\n L2 [] [num:2.] qs0 Second",
588 (1, Offset::Back(1)),
589 &[(Key::Paste("Line one\r\nLine two"), "c7-num2-1")],
590 );
591}
592
593#[test]
594fn a_list_applied_after_a_plain_paragraph_nests_under_it_and_stays_nested() {
595 let mut engine = TextEngine::default();
596 replay(
597 &mut engine,
598 &summary("pb-0"),
599 (1, Offset::Start),
600 &[(Key::Bullets, "pb-1"), (Key::Bullets, "pb-2")],
601 );
602}
603
604#[test]
605fn a_heading_continues_as_body_text_unless_split() {
606 let mut engine = TextEngine::default();
607 // `c9-h1-1.xml` and `c9-h1-2.xml`; their summaries also carry the heading's inline runs.
608 let mut editor = open(&mut engine, " L1 [] [] qs1 Heading");
609 let end = TextPosition {
610 paragraph: 0,
611 offset: 7,
612 };
613 editor.select([end; 2].into()).unwrap();
614 editor.enter(&mut engine, false).unwrap();
615 editor.insert(&mut engine, "body").unwrap();
616 assert_eq!(
617 render(&editor),
618 " L1 [] [] qs1 Heading\n L1 [] [] qs0 body"
619 );
620 let body = editor.active_outline().document.paragraph(1).unwrap();
621 assert_eq!(body.spans()[0].format, calibri(11.0, false));
622 let middle = TextPosition {
623 paragraph: 0,
624 offset: 4,
625 };
626 editor.select([middle; 2].into()).unwrap();
627 editor.enter(&mut engine, false).unwrap();
628 assert_eq!(
629 render(&editor),
630 " L1 [] [] qs1 Head\n L1 [] [] qs1 ing\n L1 [] [] qs0 body"
631 );
632}
633
634#[test]
635fn a_split_keeps_every_tag_above_and_a_join_takes_the_upper_tags() {
636 let mut engine = TextEngine::default();
637 // `c9-multitag-{1,2,3}.xml`.
638 let mut editor = open(
639 &mut engine,
640 " L1 [] [] qs0 Alpha\n L1 [To Do,Important[x]] [] qs0 Two tags",
641 );
642 let at = |offset| TextPosition {
643 paragraph: 1,
644 offset,
645 };
646 editor.select([at(4); 2].into()).unwrap();
647 editor.enter(&mut engine, false).unwrap();
648 assert_eq!(render(&editor), summary("c9-multitag-1").trim_end());
649 editor.delete(&mut engine, true).unwrap();
650 assert_eq!(render(&editor), summary("c9-multitag-2").trim_end());
651 editor.select([at(0); 2].into()).unwrap();
652 editor.delete(&mut engine, true).unwrap();
653 assert_eq!(render(&editor), summary("c9-multitag-3").trim_end());
654}
655
656/// Nesting, adopted siblings, restyled and removed lists, an outdent, a tagged split and a join
657/// that hands on children, on pages OneNote wrote, read back from the page writer unchanged.
658#[test]
659fn structural_edits_on_native_pages_survive_the_page_writer() {
660 use onestore::{RevisionIndex, Store, document::Document};
661 let section = std::fs::read(concat!(
662 env!("CARGO_MANIFEST_DIR"),
663 "/../../evidence/structural-edits/probe-section/probe.one"
664 ))
665 .unwrap();
666 let page = |bytes: &[u8], title: &str| {
667 let store = Store::parse(bytes).unwrap();
668 let index = RevisionIndex::parse(&store).unwrap();
669 let document = Document::parse(&index).unwrap();
670 let (space, _) = document
671 .pages()
672 .unwrap()
673 .into_iter()
674 .find(|(space, _)| Page::from_space(&document, *space).unwrap().title == title)
675 .unwrap();
676 (space, Page::from_space(&document, space).unwrap())
677 };
678 // Tags keep their place in the element's property arena only once read.
679 let settled = |mut page: Page| {
680 for object in &mut page.objects {
681 if let PageObject::Outline(outline) = object {
682 for paragraph in &mut outline.paragraphs {
683 for tag in &mut paragraph.text_mut().unwrap().tags {
684 tag.extra_set = 0;
685 }
686 }
687 }
688 }
689 page
690 };
691 let mut engine = TextEngine::default();
692 for (title, keys) in [
693 (
694 "c8-tab",
695 &[
696 ((2, Offset::Start), Key::ShiftTab),
697 ((4, Offset::Start), Key::Tab),
698 ((3, Offset::Start), Key::Backspace),
699 ][..],
700 ),
701 (
702 "c4-tagbullet",
703 &[
704 ((1, Offset::Start), Key::Backspace),
705 ((1, Offset::Start), Key::Backspace),
706 ],
707 ),
708 ("c2-todo", &[((1, Offset::Forward(3)), Key::Enter)]),
709 (
710 "c10-stab-child",
711 &[
712 ((2, Offset::Start), Key::Tab),
713 ((3, Offset::Start), Key::Tab),
714 ((1, Offset::End), Key::Delete),
715 ],
716 ),
717 ] {
718 let (space, source) = page(&section, title);
719 let mut editor = CanvasEditor::from_page(source, &mut engine).unwrap();
720 let body = editor
721 .outlines()
722 .iter()
723 .find(|outline| !outline.title)
724 .unwrap()
725 .id;
726 editor.focus_outline(body).unwrap();
727 for (caret, key) in keys {
728 place(&mut editor, *caret);
729 press(&mut editor, &mut engine, *key);
730 }
731 let edited = settled(editor.page().unwrap());
732 let written = onestore::PreparedEdit::page(&section, space, &edited, "Author")
733 .unwrap_or_else(|error| panic!("{title}: {error}"));
734 let (_, reread) = page(written.as_bytes(), title);
735 assert_eq!(settled(reread).objects, edited.objects, "{title}");
736 }
737}
738
739#[test]
740fn tab_inside_text_makes_a_table_whose_paragraph_keeps_the_list_tags_and_children() {
741 let mut engine = TextEngine::default();
742 // `c6-tagbullet-midtab.xml`: the table's paragraph holds the tag and bullet, its cells none.
743 let mut editor = open(&mut engine, &summary("c6-tagbullet-stab2"));
744 place(&mut editor, (1, Offset::Back(4)));
745 press(&mut editor, &mut engine, Key::Tab);
746 let nodes = editor.active_outline().document.nodes();
747 let ParagraphContent::Table(table) = &nodes[1].content else {
748 panic!("{:?}", nodes[1].content)
749 };
750 assert_eq!(
751 (nodes[1].level, nodes[1].lists.len(), nodes[1].tags.len()),
752 (1, 1, 1)
753 );
754 for cell in table.rows.iter().flat_map(|row| &row.cells) {
755 let text = cell.paragraphs[0].text().unwrap();
756 assert!(cell.paragraphs[0].lists.is_empty() && text.tags.is_empty());
757 }
758 // `c10-tab-parent-1.xml`: the children stay under the table's paragraph.
759 let mut editor = open(&mut engine, &summary("c10-tab-parent-0"));
760 place(&mut editor, (1, Offset::End));
761 press(&mut editor, &mut engine, Key::Tab);
762 let nodes = editor.active_outline().document.nodes();
763 assert!(matches!(nodes[1].content, ParagraphContent::Table(_)));
764 assert_eq!((nodes[2].parent, nodes[2].level), (Some(nodes[1].id), 2));
765}
crates/canvas/src/editor/format.rs+166-15
......@@ -52,13 +52,19 @@ pub enum Alignment {
5252 Right,
5353}
5454
55/// OneNote 2010's default tags whose stored definition local evidence shows: To Do from a
56/// native Ctrl+1 (`corpus/paragraph-edit/reconciliation/keyboard`), Question from notebooks
57/// OneNote tagged (`corpus/private/exact-native`).
55/// OneNote 2010's default tags, Ctrl+1 to Ctrl+9, as it stores their definitions
56/// (`evidence/structural-edits/tags/tags.one`); declared in that order, their action types.
5857#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5958pub enum NoteTag {
6059 ToDo,
60 Important,
6161 Question,
62 RememberForLater,
63 Definition,
64 Highlight,
65 Contact,
66 Address,
67 PhoneNumber,
6268}
6369
6470/// What the selection shows on the toolbar.
......@@ -112,20 +118,38 @@ impl Toggle {
112118}
113119
114120impl NoteTag {
115 const ALL: [Self; 2] = [Self::ToDo, Self::Question];
121 pub(super) const ALL: [Self; 9] = [
122 Self::ToDo,
123 Self::Important,
124 Self::Question,
125 Self::RememberForLater,
126 Self::Definition,
127 Self::Highlight,
128 Self::Contact,
129 Self::Address,
130 Self::PhoneNumber,
131 ];
116132
117 fn definition(self) -> Definition {
118 let (label, action_type, shape) = match self {
119 Self::ToDo => ("To Do", 0, CHECKBOX),
120 Self::Question => ("Question", 2, 15),
133 pub(super) fn definition(self) -> Definition {
134 let (label, shape, highlight) = match self {
135 Self::ToDo => ("To Do", CHECKBOX, None),
136 Self::Important => ("Important", 13, None),
137 Self::Question => ("Question", 15, None),
138 Self::RememberForLater => ("Remember for later", 0, Some(0x0000_ffff)),
139 Self::Definition => ("Definition", 0, Some(0x0000_ff00)),
140 Self::Highlight => ("Highlight", 136, None),
141 Self::Contact => ("Contact", 118, None),
142 Self::Address => ("Address", 23, None),
143 Self::PhoneNumber => ("Phone number", 18, None),
121144 };
122145 Definition {
123146 kind: Kind::TagDefinition {
124147 label: Some(label.into()),
125 action_type: Some(action_type),
148 action_type: Some(self as u16),
126149 shape: Some(shape),
127 color: None,
128 highlight: None,
150 // The highlighting tags also set black text.
151 color: highlight.map(|_| 0),
152 highlight,
129153 },
130154 format: Format::default(),
131155 }
......@@ -391,7 +415,7 @@ impl CanvasEditor {
391415 ) -> Result<(), EditorError> {
392416 let outline = self.active_outline();
393417 let (id, title, selection) = (outline.id, outline.title, outline.selection);
394 let (container, range, ends) = selected(&outline.document, selection)?;
418 let (container, mut range, ends) = selected(&outline.document, selection)?;
395419 let mut replacement = outline.document.container(container)?[range.clone()].to_vec();
396420 let mut ranges = covered(&replacement, ends)
397421 .map(|(node, range)| (node.id, range))
......@@ -434,6 +458,18 @@ impl CanvasEditor {
434458 Formatting::Bullets | Formatting::Numbering => {
435459 let remove = covered(&replacement, ends)
436460 .all(|(node, _)| self.list(node).as_ref() == Some(&command));
461 // A list applied after a plain sibling nests under it, as Tab would
462 // (`evidence/structural-edits/xml/pb-1.xml`); removing it leaves it there.
463 let nodes = outline.document.container(container)?;
464 if !remove
465 && crate::document::previous_sibling(nodes, range.start, &BTreeSet::new())
466 .is_some_and(|sibling| nodes[sibling].lists.is_empty())
467 && let Some(edit) =
468 crate::document::indent(nodes, container, range.clone(), false)
469 {
470 range = edit.range;
471 replacement = edit.replacement;
472 }
437473 let wanted = covered(&replacement, ends)
438474 .map(|(node, _)| node)
439475 .filter(|node| !remove && self.list(node).as_ref() != Some(&command))
......@@ -632,10 +668,73 @@ impl CanvasEditor {
632668 }
633669}
634670
671/// The list a Tab (`deeper`) or Shift+Tab gives a paragraph with a default list, as OneNote
672/// 2010 steps • to ○ and 1. to a. (`evidence/structural-edits/xml/c6-bullet-tab.xml`,
673/// `c8-tab-1.xml`), then to â–  as `corpus/private` nests bullets, and to i.; outdenting stops at
674/// the first style.
675pub(super) fn nested_list(definition: &Definition, deeper: bool) -> Option<Definition> {
676 const BULLETS: [(&str, &str, u16); 3] = [
677 ("Calibri", "\u{2022}", 1),
678 ("Courier New", "\u{25cb}", 4),
679 ("Wingdings", "\u{a7}", 7),
680 ];
681 const SEQUENCES: [char; 3] = ['\0', '\u{4}', '\u{2}'];
682 let Kind::List {
683 font,
684 format: Some(format),
685 restart,
686 bullet,
687 } = &definition.kind
688 else {
689 return None;
690 };
691 let step = |index: usize| {
692 if deeper {
693 Some((index + 1) % 3)
694 } else {
695 index.checked_sub(1)
696 }
697 };
698 let kind = match bullet {
699 Some(_) => {
700 let index = BULLETS.iter().position(|(name, glyph, index)| {
701 (font.as_deref(), format.as_str(), *bullet) == (Some(*name), *glyph, Some(*index))
702 })?;
703 let (name, glyph, index) = BULLETS[step(index)?];
704 Kind::List {
705 font: Some(name.into()),
706 format: Some(glyph.into()),
707 restart: *restart,
708 bullet: Some(index),
709 }
710 }
711 None => {
712 let (prefix, rest) = format.split_once('\u{fffd}')?;
713 let mut rest = rest.chars();
714 let sequence = rest.next()?;
715 let index = SEQUENCES.iter().position(|known| *known == sequence)?;
716 Kind::List {
717 font: font.clone(),
718 format: Some(format!(
719 "{prefix}\u{fffd}{}{}",
720 SEQUENCES[step(index)?],
721 rest.as_str()
722 )),
723 restart: *restart,
724 bullet: None,
725 }
726 }
727 };
728 Some(Definition {
729 kind,
730 format: definition.format.clone(),
731 })
732}
733
635734/// The list OneNote 2010 gives a paragraph with `format`: its Ctrl+. bullet
636735/// (`corpus/paragraph-edit/reconciliation/keyboard`), or the `##.` arabic numbering it stores
637736/// from its COM interface (`corpus/outline-edit/tree`).
638fn list_definition(numbering: bool, format: &Format) -> Definition {
737pub(super) fn list_definition(numbering: bool, format: &Format) -> Definition {
639738 let font_size = Some(format.font_size.unwrap_or(11.0));
640739 if numbering {
641740 Definition {
......@@ -1007,9 +1106,10 @@ mod tests {
10071106 .as_ptr();
10081107 editor.select([at(1, 1); 2].into()).unwrap();
10091108 editor.format(&mut engine, Formatting::Numbering).unwrap();
1109 // An unnumbered sibling leaves the count alone (`evidence/structural-edits/xml/c8-bs-1.xml`).
10101110 assert_eq!(
10111111 numbers(&mut engine, &editor),
1012 [Some(1), None, Some(1), Some(2)]
1112 [Some(1), None, Some(2), Some(3)]
10131113 );
10141114 assert_eq!(
10151115 editor.active_outline().shaped.paragraphs[0]
......@@ -1036,7 +1136,7 @@ mod tests {
10361136 editor.format(&mut engine, Formatting::Outdent).unwrap();
10371137 assert_eq!(
10381138 numbers(&mut engine, &editor),
1039 [Some(1), Some(2), None, Some(1)]
1139 [Some(1), Some(2), None, Some(3)]
10401140 );
10411141 editor.select(all).unwrap();
10421142 let state = editor.format_state().unwrap();
......@@ -1205,6 +1305,57 @@ mod tests {
12051305 assert_eq!(editor.active_outline().document, original);
12061306 }
12071307
1308 #[test]
1309 fn the_nine_default_tags_are_onenotes_stored_definitions_and_all_draw() {
1310 use onestore::{RevisionIndex, Store, document::Document};
1311 let bytes = include_bytes!("../../../../evidence/structural-edits/tags/tags.one");
1312 let store = Store::parse(bytes).unwrap();
1313 let index = RevisionIndex::parse(&store).unwrap();
1314 let document = Document::parse(&index).unwrap();
1315 let (space, _) = document.pages().unwrap()[0];
1316 let stored = Page::from_space(&document, space).unwrap().definitions;
1317 let stored = stored
1318 .values()
1319 .filter(|definition| matches!(definition.kind, Kind::TagDefinition { .. }))
1320 .collect::<Vec<_>>();
1321 assert_eq!(stored.len(), NoteTag::ALL.len());
1322 for tag in NoteTag::ALL {
1323 assert!(stored.contains(&&tag.definition()), "{tag:?}");
1324 }
1325 let mut engine = TextEngine::default();
1326 let mut editor = plain(&mut engine, &["tagged"]);
1327 for tag in NoteTag::ALL {
1328 editor.format(&mut engine, Formatting::Tag(tag)).unwrap();
1329 }
1330 assert_eq!(editor.format_state().unwrap().tags, NoteTag::ALL);
1331 let paragraph = &editor.active_outline().shaped.paragraphs[0];
1332 use crate::outline::TagIcon;
1333 assert_eq!(
1334 paragraph
1335 .tags
1336 .iter()
1337 .map(|tag| tag.icon)
1338 .collect::<Vec<_>>(),
1339 [
1340 TagIcon::CheckBox { checked: false },
1341 TagIcon::Star,
1342 TagIcon::Question,
1343 TagIcon::Highlight,
1344 TagIcon::Contact,
1345 TagIcon::Address,
1346 TagIcon::Phone,
1347 ]
1348 );
1349 // Remember for later and Definition draw no symbol; the newer one's green marks the text.
1350 assert!(
1351 paragraph
1352 .text
1353 .backgrounds()
1354 .all(|(_, color)| color == 0x0000_ff00)
1355 );
1356 assert!(paragraph.text.backgrounds().next().is_some());
1357 }
1358
12081359 #[test]
12091360 fn commands_reach_every_cell_a_selection_crosses() {
12101361 let mut engine = TextEngine::default();
crates/canvas/src/editor/page.rs+14-6
......@@ -47,6 +47,19 @@ pub struct ReadOnlyObject {
4747}
4848
4949impl Content {
50 /// A picture drawn as a placeholder because it has no data, size or decodable pixels.
51 pub(crate) fn unavailable(
52 source: PageObject,
53 engine: &mut TextEngine,
54 ) -> Result<Self, EditorError> {
55 Ok(Self::ReadOnly(ReadOnlyObject::new(
56 source,
57 [0.0; 2],
58 "Image unavailable\nRead-only",
59 engine,
60 )?))
61 }
62
5063 pub(super) fn layout(&self) -> Option<(onestore::ExGuid, &onestore::document::Layout)> {
5164 match self {
5265 Self::Outline { source, .. } => Some((source.id, &source.layout)),
......@@ -290,12 +303,7 @@ pub(crate) fn build(
290303 || source.layout.max_width.is_none()
291304 || source.layout.max_height.is_none()
292305 {
293 objects.push(Content::ReadOnly(ReadOnlyObject::new(
294 object,
295 page.margin_origin,
296 "Image unavailable\nRead-only",
297 engine,
298 )?));
306 objects.push(Content::unavailable(object, engine)?);
299307 continue;
300308 }
301309 let origin = [
crates/canvas/src/editor/table.rs+25-6
......@@ -101,12 +101,23 @@ impl CanvasEditor {
101101 Paragraph::new(String::new(), format.clone()),
102102 ],
103103 )?;
104 let id = new_id()?;
105 // The table's paragraph holds the list, tags and children (`evidence/structural-edits/
106 // xml/c6-*-midtab.xml`, `c10-tab-parent-1.xml`).
107 let children = split.replacement.split_off(2);
108 let head = &mut split.replacement[0];
109 let lists = std::mem::take(&mut head.lists);
110 let mut tags = std::mem::take(&mut head.tags);
111 tags.append(&mut head.text_mut().unwrap().tags);
112 let collapsed = std::mem::take(&mut split.replacement[1].collapsed);
113 let tail = split.replacement[1].id;
104114 let cells = split
105115 .replacement
106116 .drain(..)
107117 .map(|mut paragraph| {
108118 paragraph.level = 1;
109119 paragraph.parent = None;
120 paragraph.lists.clear();
110121 Ok(TableCell {
111122 id: new_id()?,
112123 layout: Default::default(),
......@@ -118,15 +129,15 @@ impl CanvasEditor {
118129 })
119130 .collect::<Result<Vec<_>, EditError>>()?;
120131 let wrapper = PageParagraph {
121 id: new_id()?,
132 id,
122133 parent: source.parent,
123134 level: source.level,
124135 style: None,
125136 format: source.format.clone(),
126 lists: Vec::new(),
127 tags: Vec::new(),
137 lists,
138 tags,
128139 media: Default::default(),
129 collapsed: false,
140 collapsed,
130141 content: ParagraphContent::Table(onestore::page::Table {
131142 id: new_id()?,
132143 columns: vec![
......@@ -146,6 +157,14 @@ impl CanvasEditor {
146157 }),
147158 };
148159 split.replacement.push(wrapper);
160 split
161 .replacement
162 .extend(children.into_iter().map(|mut child| {
163 if child.parent == Some(tail) {
164 child.parent = Some(id);
165 }
166 child
167 }));
149168 return self.commit(
150169 engine,
151170 split,
......@@ -261,7 +280,7 @@ impl CanvasEditor {
261280 .leaf(focus.paragraph)
262281 .ok_or(EditError::InvalidRange)?;
263282 let Some(cell) = cell.filter(|_| anchor == focus) else {
264 return self.insert(engine, "\n");
283 return self.split(engine);
265284 };
266285 let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?;
267286 let ParagraphContent::Table(table) = &location.node.content else {
......@@ -284,7 +303,7 @@ impl CanvasEditor {
284303 && local + 1 == row.cells[location.column].paragraphs.len()
285304 && focus.offset == text.utf16_offset(text.text().len())?;
286305 if !exit && !append {
287 return self.insert(engine, "\n");
306 return self.split(engine);
288307 }
289308 let mut wrapper = location.node.clone();
290309 let ParagraphContent::Table(table) = &mut wrapper.content else {
crates/canvas/src/gpu/mod.rs+16-1
......@@ -98,7 +98,12 @@ pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout {
9898
9999const CHECKBOX: &str = include_str!("../../assets/tags/checkbox.svg");
100100const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg");
101const STAR: &str = include_str!("../../assets/tags/star.svg");
101102const QUESTION: &str = include_str!("../../assets/tags/question.svg");
103const HIGHLIGHT: &str = include_str!("../../assets/tags/highlight.svg");
104const CONTACT: &str = include_str!("../../assets/tags/contact.svg");
105const ADDRESS: &str = include_str!("../../assets/tags/address.svg");
106const PHONE: &str = include_str!("../../assets/tags/phone.svg");
102107const MUSIC: &str = include_str!("../../assets/tags/music.svg");
103108const EXCLAMATION: &str = include_str!("../../assets/tags/exclamation.svg");
104109const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg");
......@@ -110,7 +115,12 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
110115 match icon {
111116 TagIcon::CheckBox { checked: false } => &[CHECKBOX],
112117 TagIcon::CheckBox { checked: true } => &[CHECKBOX, CHECKMARK],
118 TagIcon::Star => &[STAR],
113119 TagIcon::Question => &[QUESTION],
120 TagIcon::Highlight => &[HIGHLIGHT],
121 TagIcon::Contact => &[CONTACT],
122 TagIcon::Address => &[ADDRESS],
123 TagIcon::Phone => &[PHONE],
114124 TagIcon::Music => &[MUSIC],
115125 TagIcon::Exclamation => &[EXCLAMATION],
116126 TagIcon::RedSquare => &[RED_SQUARE],
......@@ -140,7 +150,7 @@ mod tests {
140150 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
141151 let (device, queue) =
142152 pollster::block_on(adapter.request_device(&Default::default())).unwrap();
143 let size = [256, 64];
153 let size = [448, 64];
144154 let target = device.create_texture(&wgpu::TextureDescriptor {
145155 label: Some("Tag readback test"),
146156 size: wgpu::Extent3d {
......@@ -165,7 +175,12 @@ mod tests {
165175 let icons = [
166176 TagIcon::CheckBox { checked: false },
167177 TagIcon::CheckBox { checked: true },
178 TagIcon::Star,
168179 TagIcon::Question,
180 TagIcon::Highlight,
181 TagIcon::Contact,
182 TagIcon::Address,
183 TagIcon::Phone,
169184 TagIcon::Music,
170185 TagIcon::Exclamation,
171186 TagIcon::RedSquare,
crates/canvas/src/gpu/page.rs+41-10
......@@ -92,10 +92,13 @@ fn append_placeholder(rect: [f32; 4], paper: Paper, primitives: &mut Vec<Primiti
9292 rect,
9393 color: paper.shade(colorref(0x00e4ddd6)),
9494 });
95 primitives.push(Primitive::Rect {
96 rect: [rect[0] + 1.0, rect[1] + 1.0, rect[2] - 1.0, rect[3] - 1.0],
97 color: paper.shade(colorref(0x00faf7f3)),
98 });
95 let inner = [rect[0] + 1.0, rect[1] + 1.0, rect[2] - 1.0, rect[3] - 1.0];
96 if inner[0] < inner[2] && inner[1] < inner[3] {
97 primitives.push(Primitive::Rect {
98 rect: inner,
99 color: paper.shade(colorref(0x00faf7f3)),
100 });
101 }
99102}
100103
101104#[derive(Debug)]
......@@ -140,10 +143,11 @@ impl From<EditorError> for SceneError {
140143
141144impl PageScene {
142145 pub fn new(mut page: Page, engine: &mut TextEngine) -> Result<Self, SceneError> {
143 let objects = crate::editor::page::build(&mut page, engine, false)
146 let mut objects = crate::editor::page::build(&mut page, engine, false)
144147 .map_err(SceneError::from)?
145148 .objects;
146149 let pictures = Self::decode_images(&objects, None)?;
150 pictures.mark_unavailable(&mut objects, engine)?;
147151 Ok(Self {
148152 reference: Some(objects),
149153 ..pictures
......@@ -154,12 +158,35 @@ impl PageScene {
154158 page: Page,
155159 engine: &mut TextEngine,
156160 ) -> Result<(Self, CanvasEditor), SceneError> {
157 let editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?;
158 Ok((Self::decode_images(&editor.objects, Some(&editor))?, editor))
161 let mut editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?;
162 let scene = Self::decode_images(&editor.objects, Some(&editor))?;
163 scene.mark_unavailable(&mut editor.objects, engine)?;
164 Ok((scene, editor))
165 }
166
167 /// Turns page pictures that did not decode into placeholders; their stored data is kept.
168 fn mark_unavailable(
169 &self,
170 objects: &mut Vec<Content>,
171 engine: &mut TextEngine,
172 ) -> Result<(), SceneError> {
173 *objects = std::mem::take(objects)
174 .into_iter()
175 .map(|object| match object {
176 Content::Image(source)
177 if !self.images.contains_key(&source.id)
178 && !self.backgrounds.contains_key(&source.id) =>
179 {
180 Content::unavailable(onestore::page::PageObject::Image(source), engine)
181 }
182 object => Ok(object),
183 })
184 .collect::<Result<_, _>>()?;
185 Ok(())
159186 }
160187
161188 /// Decodes every picture the page draws, page-level ones and those inside outlines,
162 /// except the template backgrounds it recognises.
189 /// except the template backgrounds it recognises; one without decodable data is left out.
163190 fn decode_images(
164191 objects: &[Content],
165192 editor: Option<&CanvasEditor>,
......@@ -215,8 +242,9 @@ impl PageScene {
215242 let mut images = std::collections::BTreeMap::new();
216243 let mut bytes = 0_u64;
217244 for (id, encoded) in payloads {
218 let image = RasterImage::decode(encoded.ok_or(SceneError::MissingImage)?)
219 .map_err(SceneError::Image)?;
245 let Some(image) = encoded.and_then(|encoded| RasterImage::decode(encoded).ok()) else {
246 continue;
247 };
220248 bytes += image.pixels().len() as u64;
221249 if bytes > draw::MAX_IMAGE_BYTES {
222250 return Err(SceneError::Image(RenderError::ImageBudget));
......@@ -284,6 +312,9 @@ impl PageScene {
284312 crate::outline::ObjectKind::Unsupported(_) => {
285313 append_placeholder(rect, paper, primitives)
286314 }
315 crate::outline::ObjectKind::Picture if !self.images.contains_key(&object.id) => {
316 append_placeholder(rect, paper, primitives)
317 }
287318 crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {}
288319 }
289320 if let Some(label) = object.label() {
crates/canvas/src/interaction/accessibility.rs+5
......@@ -597,7 +597,12 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32>
597597 use crate::outline::TagIcon;
598598 let fallback = match tag.icon {
599599 TagIcon::CheckBox { .. } => "To do",
600 TagIcon::Star => "Important",
600601 TagIcon::Question => "Question",
602 TagIcon::Highlight => "Highlight",
603 TagIcon::Contact => "Contact",
604 TagIcon::Address => "Address",
605 TagIcon::Phone => "Phone number",
601606 TagIcon::Music => "Music",
602607 TagIcon::Exclamation => "Critical",
603608 TagIcon::RedSquare => "Project A",
crates/canvas/src/interaction/mod.rs+10-1
......@@ -819,7 +819,7 @@ impl PageView {
819819 self.commit_text(text)
820820 }
821821
822 /// Text committed by an input method or pasted, line breaks included.
822 /// Text committed by an input method, line breaks included.
823823 pub fn commit_text(&mut self, text: String) -> Result<Response> {
824824 if !self.accepts_text() {
825825 return Ok(Response::default());
......@@ -828,6 +828,15 @@ impl PageView {
828828 self.edited()
829829 }
830830
831 /// Clipboard text; see [`CanvasEditor::paste`].
832 pub fn paste(&mut self, text: &str) -> Result<Response> {
833 if !self.accepts_text() {
834 return Ok(Response::default());
835 }
836 self.editor.paste(&mut self.engine, text)?;
837 self.edited()
838 }
839
831840 /// An input method's marked text; `cursor` is its UTF-8 selection within `text`.
832841 pub fn compose(&mut self, text: String, cursor: Option<(usize, usize)>) -> Result<Response> {
833842 if !self.accepts_text() {
crates/canvas/src/outline.rs+55-9
......@@ -42,6 +42,8 @@ struct Block {
4242 state: (f64, Option<f32>),
4343 /// A table's vertical coordinates before its offset.
4444 rel: Vec<f32>,
45 /// The number the next numbered sibling at the node's level continues from.
46 count: Option<u32>,
4547}
4648
4749/// A layout of root nodes `range` once an edit applies, and how the nodes after them move.
......@@ -142,6 +144,8 @@ fn verticals<'a>(
142144
143145/// OneNote centers a file's icon and name in a column this wide.
144146const ATTACHMENT_WIDTH: f32 = 54.0;
147/// The grey OneNote gives secondary text such as the page date (its `PageDateTime` style).
148const PLACEHOLDER: u32 = 0x0080_8080;
145149
146150#[derive(Clone)]
147151pub struct TableLayout {
......@@ -201,7 +205,12 @@ pub struct ParagraphLayout {
201205#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
202206pub enum TagIcon {
203207 CheckBox { checked: bool },
208 Star,
204209 Question,
210 Highlight,
211 Contact,
212 Address,
213 Phone,
205214 Music,
206215 Exclamation,
207216 RedSquare,
......@@ -255,8 +264,16 @@ pub(crate) fn image_size(image: &onestore::page::Image) -> Option<[f32; 2]> {
255264 .then_some(size)
256265}
257266
267/// The table OneNote writes on every outline it creates, standing in for a missing one.
268const DEFAULT_INDENTS: [f32; 4] = [18.0, 0.0, 27.0, 27.0];
269
258270pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32, LayoutError> {
259 if indents.is_empty() || indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 {
271 let indents = if indents.is_empty() {
272 &DEFAULT_INDENTS
273 } else {
274 indents
275 };
276 if indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 {
260277 return Err(LayoutError::InvalidIndentation);
261278 }
262279 let known = (level as usize).min(indents.len() - 1);
......@@ -282,8 +299,19 @@ fn follows(number: Option<(u32, bool)>, previous: Option<u32>) -> Option<u32> {
282299 number.map(|(number, restart)| if restart { number } else { next(previous) })
283300}
284301
285/// Removes deeper paragraphs' entries from a flow's `(level, number)` stack of the latest
286/// paragraph at each level, and the previous sibling's at `level`, returning that one's number.
302/// What the numbered sibling after `node`, numbered `number`, continues from: siblings without
303/// a number leave the count alone, as do empty numbered ones after another number
304/// (`evidence/structural-edits/xml/c8-bs-1.xml`, `c8-enter-empty-1.xml`, `c2s-num-1.xml`).
305fn tally(node: &PageParagraph, number: Option<u32>, previous: Option<u32>) -> Option<u32> {
306 number
307 .filter(|_| {
308 previous.is_none() || node.text().is_some_and(|text| !text.text.text().is_empty())
309 })
310 .or(previous)
311}
312
313/// Removes deeper paragraphs' entries from a flow's `(level, count)` stack of the latest
314/// paragraph at each level, and the previous sibling's at `level`, returning that one's count.
287315fn sibling(siblings: &mut Vec<(u32, Option<u32>)>, level: u32) -> Option<u32> {
288316 while siblings.last().is_some_and(|(deeper, _)| *deeper > level) {
289317 siblings.pop();
......@@ -296,7 +324,7 @@ fn sibling(siblings: &mut Vec<(u32, Option<u32>)>, level: u32) -> Option<u32> {
296324
297325/// `number` in a list numbering sequence: 0 arabic, 1 and 2 upper and lower roman, 3 and 4
298326/// upper and lower letters, as OneNote 2010's `numberSequence`.
299fn numeral(sequence: Option<char>, number: u32) -> Result<String, LayoutError> {
327pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, LayoutError> {
300328 let roman = |number: u32| {
301329 const DIGITS: [(u32, &str); 13] = [
302330 (1000, "M"),
......@@ -471,8 +499,13 @@ impl ParagraphLayout {
471499 else {
472500 return Err(LayoutError::InvalidList);
473501 };
502 let mut color = definition.format.color;
474503 let value = match value.split_once('\u{fffd}') {
475504 Some((prefix, rest)) => {
505 // An empty numbered paragraph shows its number as a grey placeholder.
506 if source.text.text().is_empty() {
507 color = Some(PLACEHOLDER);
508 }
476509 let current = restart.unwrap_or(next(previous));
477510 number = Some((current, restart.is_some()));
478511 let mut rest = rest.chars();
......@@ -492,7 +525,7 @@ impl ParagraphLayout {
492525 Format {
493526 font: font.clone().or_else(|| definition.format.font.clone()),
494527 font_size: definition.format.font_size.or(format.font_size),
495 color: definition.format.color,
528 color,
496529 ..Format::default()
497530 },
498531 );
......@@ -517,8 +550,13 @@ impl ParagraphLayout {
517550 Some(3) => TagIcon::CheckBox {
518551 checked: tag.status & 1 != 0,
519552 },
553 Some(13) => TagIcon::Star,
520554 Some(15) => TagIcon::Question,
521555 Some(17) => TagIcon::Exclamation,
556 Some(18) => TagIcon::Phone,
557 Some(23) => TagIcon::Address,
558 Some(118) => TagIcon::Contact,
559 Some(136) => TagIcon::Highlight,
522560 Some(100) => TagIcon::RedSquare,
523561 Some(101) => TagIcon::YellowSquare,
524562 Some(102) => TagIcon::BlueSquare,
......@@ -702,6 +740,7 @@ impl OutlineLayout {
702740 extent: [f32::NEG_INFINITY; 2],
703741 state,
704742 rel: Vec::new(),
743 count: None,
705744 };
706745 if !hidden {
707746 let (space, height, flow, extent) = match &node.content {
......@@ -836,7 +875,8 @@ impl OutlineLayout {
836875 return Err(LayoutError::InvalidSpacing);
837876 }
838877 }
839 siblings.push((node.level, number));
878 block.count = tally(node, number, previous);
879 siblings.push((node.level, block.count));
840880 block.state = state;
841881 if depth == 0 {
842882 result.blocks.push(block);
......@@ -932,7 +972,7 @@ impl OutlineLayout {
932972 for root in (0..range.start).rev() {
933973 if nodes[root].level < level {
934974 level = nodes[root].level;
935 siblings.push((level, self.number(nodes, root).map(|(number, _)| number)));
975 siblings.push((level, self.blocks[root].count));
936976 if level <= 1 {
937977 break;
938978 }
......@@ -990,10 +1030,11 @@ impl OutlineLayout {
9901030 let previous = sibling(&mut siblings, nodes[root].level);
9911031 let number = self.number(nodes, root);
9921032 let now = follows(number, previous);
993 if now != number.map(|(number, _)| number) {
1033 let counted = tally(&nodes[root], now, previous);
1034 if now != number.map(|(number, _)| number) || counted != self.blocks[root].count {
9941035 renumbered = root + 1;
9951036 }
996 siblings.push((nodes[root].level, now));
1037 siblings.push((nodes[root].level, counted));
9971038 if nodes[root].level <= 1 && renumbered <= root {
9981039 break;
9991040 }
......@@ -1678,8 +1719,13 @@ mod tests {
16781719 });
16791720 for (shape, expected) in [
16801721 (3, TagIcon::CheckBox { checked: true }),
1722 (13, TagIcon::Star),
16811723 (15, TagIcon::Question),
16821724 (17, TagIcon::Exclamation),
1725 (18, TagIcon::Phone),
1726 (23, TagIcon::Address),
1727 (118, TagIcon::Contact),
1728 (136, TagIcon::Highlight),
16831729 (100, TagIcon::RedSquare),
16841730 (101, TagIcon::YellowSquare),
16851731 (102, TagIcon::BlueSquare),
crates/canvas/tests/reopen.rs created+181
......@@ -0,0 +1,181 @@
1#![cfg(feature = "gpu")]
2
3use canvas::{gpu::page::PageScene, layout::TextEngine};
4use onestore::{
5 ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store,
6 document::Document,
7 page::{Page, PageObject, ParagraphContent},
8};
9use std::{fs, path::Path};
10
11fn pages(section: &[u8]) -> Vec<Page> {
12 let store = Store::parse(section).unwrap();
13 let index = RevisionIndex::parse(&store).unwrap();
14 let document = Document::parse(&index).unwrap();
15 spaces(&document)
16 .into_iter()
17 .map(|space| Page::from_space(&document, space).unwrap())
18 .collect()
19}
20
21fn spaces(document: &Document<'_>) -> Vec<ExGuid> {
22 let mut spaces: Vec<_> = document
23 .pages()
24 .unwrap()
25 .into_iter()
26 .map(|(space, _)| space)
27 .collect();
28 spaces.dedup();
29 spaces
30}
31
32/// What `notebook::session::Section::import_page` writes: a new titled page, then the
33/// editor's body objects saved onto it.
34#[test]
35fn an_imported_editor_page_reopens_with_its_title_editable() {
36 let section = onestore::create_section("reopen.one", "Body 🦀 é", "Author").unwrap();
37 let mut engine = TextEngine::default();
38 let (_, editor) = PageScene::from_page(pages(&section).remove(0), &mut engine).unwrap();
39 let copy = editor.page().unwrap().copy().unwrap();
40 let creation = PageCreation::new(None, Some("Imported 🦋"), "Author").unwrap();
41 let created = PreparedEdit::create_page(&section, &creation).unwrap();
42 let mut after = pages(created.as_bytes()).pop().unwrap();
43 after
44 .objects
45 .retain(|object| matches!(object, PageObject::Title(_)));
46 after.objects.extend(
47 copy.objects
48 .into_iter()
49 .filter(|object| !matches!(object, PageObject::Title(_))),
50 );
51 after.definitions = copy.definitions;
52 let saved = PreparedEdit::page(created.as_bytes(), creation.space(), &after, "Author").unwrap();
53
54 let reopened = pages(saved.as_bytes()).pop().unwrap();
55 let (_, editor) = PageScene::from_page(reopened.clone(), &mut engine).unwrap();
56 assert_eq!(editor.page().unwrap(), reopened);
57 let texts = |title: bool| -> Vec<String> {
58 editor
59 .outlines()
60 .iter()
61 .filter(|outline| outline.title == title)
62 .map(|outline| {
63 outline
64 .document()
65 .paragraphs()
66 .next()
67 .unwrap()
68 .text()
69 .to_string()
70 })
71 .collect()
72 };
73 assert_eq!(texts(true), ["Imported 🦋"]);
74 assert_eq!(texts(false), ["Body 🦀 é"]);
75}
76
77#[test]
78fn every_corpus_page_opens() {
79 fn sections(dir: &Path, found: &mut Vec<std::path::PathBuf>) {
80 for entry in fs::read_dir(dir).unwrap() {
81 let path = entry.unwrap().path();
82 if path.is_dir() {
83 sections(&path, found);
84 } else if path.extension().is_some_and(|extension| extension == "one") {
85 found.push(path);
86 }
87 }
88 }
89 let mut found = Vec::new();
90 sections(
91 &Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"),
92 &mut found,
93 );
94 let mut engine = TextEngine::default();
95 let (mut pages, mut failures) = (0, Vec::new());
96 for path in found {
97 let bytes = fs::read(&path).unwrap();
98 // Malformed and encrypted sections are the reader's tests' concern.
99 let Ok(store) = Store::parse(&bytes) else {
100 continue;
101 };
102 let Ok(index) = RevisionIndex::parse(&store) else {
103 continue;
104 };
105 let Ok(document) = Document::parse(&index) else {
106 continue;
107 };
108 for space in spaces(&document) {
109 pages += 1;
110 let result = Page::from_space(&document, space)
111 .map_err(|error| format!("{error:?}"))
112 .and_then(|page| {
113 PageScene::from_page(page, &mut engine).map_err(|error| format!("{error:?}"))
114 });
115 if let Err(error) = result {
116 failures.push(format!("{} {space}: {error}", path.display()));
117 }
118 }
119 }
120 assert!(pages > 2000, "{pages}");
121 assert!(failures.is_empty(), "{}", failures.join("\n"));
122}
123
124/// A page opens whatever its pictures hold, and saving keeps their stored data.
125#[test]
126fn undecodable_pictures_show_placeholders_and_keep_their_data() {
127 let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus");
128 let section = |path: &str| pages(&fs::read(corpus.join(path)).unwrap());
129 let mut engine = TextEngine::default();
130
131 // A PNG whose checksum does not match, inside an outline.
132 let page = section("writer/complex-01/notebook/synthetic.one")
133 .into_iter()
134 .find(|page| page.title.starts_with("Fictitious"))
135 .unwrap();
136 let picture = page
137 .objects
138 .iter()
139 .find_map(|object| match object {
140 PageObject::Outline(outline) => {
141 outline
142 .paragraphs
143 .iter()
144 .find_map(|paragraph| match &paragraph.content {
145 ParagraphContent::Image(image) => Some(image.id),
146 _ => None,
147 })
148 }
149 _ => None,
150 })
151 .unwrap();
152 let (scene, editor) = PageScene::from_page(page.clone(), &mut engine).unwrap();
153 assert!(scene.image(picture).is_none());
154 assert_eq!(editor.page().unwrap(), page);
155
156 // A TIFF on the page renders; with its data damaged it becomes the placeholder.
157 let mut page = section("media-edit/candidate/Features.one")
158 .into_iter()
159 .find(|page| page.title == "Image tiff")
160 .unwrap();
161 let (scene, _) = PageScene::from_page(page.clone(), &mut engine).unwrap();
162 let PageObject::Image(tiff) = page
163 .objects
164 .iter_mut()
165 .find(|object| matches!(object, PageObject::Image(_)))
166 .unwrap()
167 else {
168 unreachable!()
169 };
170 assert!(scene.image(tiff.id).is_some());
171 tiff.bytes = Some(b"not a picture".as_slice().into());
172 let (scene, editor) = PageScene::from_page(page.clone(), &mut engine).unwrap();
173 assert_eq!(
174 scene
175 .read_only(Some(&editor))
176 .map(|object| object.message)
177 .collect::<Vec<_>>(),
178 ["Image unavailable\nRead-only"]
179 );
180 assert_eq!(editor.page().unwrap(), page);
181}
crates/draw/Cargo.toml+1-1
......@@ -13,7 +13,7 @@ render = ["dep:bytemuck", "dep:image", "dep:roxmltree", "dep:swash", "dep:wgpu"]
1313parley.workspace = true
1414
1515bytemuck = { version = "1.25", features = ["derive"], optional = true }
16image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"], optional = true }
16image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true }
1717roxmltree = { version = "0.21", optional = true }
1818swash = { version = "0.2.10", optional = true }
1919wgpu = { workspace = true, optional = true }
crates/onestore/src/page/mod.rs+14-3
......@@ -624,11 +624,22 @@ impl Page {
624624 Ok(width)
625625 })
626626 .transpose()?;
627 let title_text = |node: &Element<'_>| {
628 node.extra.first().is_some_and(|fields| {
629 fields.iter().any(|field| {
630 field.id == 0x88001cb4 && matches!(field.value, FieldValue::NoData)
631 })
632 })
633 };
627634 let mut outline = Outline {
628635 id,
629 title: fields.iter().any(|field| {
630 field.id == 0x88001cb4 && matches!(field.value, FieldValue::NoData)
631 }),
636 // OneNote flags the outline and its paragraph; `PageCreation` flags only
637 // the paragraph, and OneNote still shows that outline as the title.
638 title: title_text(node)
639 || node
640 .children
641 .iter()
642 .any(|id| revision.nodes.get(id).is_some_and(title_text)),
632643 min_width,
633644 layout: node.layout.clone(),
634645 indents: indents.clone(),
crates/snowbound/assets/tags/address.svg deleted-34
......@@ -1,34 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#9a4a2a"/>
5 <stop offset="1" stop-color="#7a3218"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#b58a3a"/>
9 <stop offset="1" stop-color="#8a6420"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#fff1b0"/>
13 <stop offset="1" stop-color="#f2c25c"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#e0672e"/>
17 <stop offset="1" stop-color="#a8401a"/>
18 </linearGradient>
19 <linearGradient id="layer4" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
20 <stop offset="0" stop-color="#9a5a1e"/>
21 <stop offset="1" stop-color="#6e3a10"/>
22 </linearGradient>
23 <linearGradient id="layer5" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
24 <stop offset="0" stop-color="#e3a54c"/>
25 <stop offset="1" stop-color="#c07a26"/>
26 </linearGradient>
27 </defs>
28 <path d="M10.5 1.5H13V5.5L10.5 3.5Z" fill="url(#layer0)"/>
29 <path d="M3 7H13V15H3Z" fill="url(#layer1)"/>
30 <path d="M4 7.5H12V14H4Z" fill="url(#layer2)"/>
31 <path d="M8 0.75L15.25 8L14 9.25L8 3.25L2 9.25L0.75 8Z" fill="url(#layer3)"/>
32 <path d="M6.75 9.5H9.25V14H6.75Z" fill="url(#layer4)"/>
33 <path d="M7.25 10H8.75V14H7.25Z" fill="url(#layer5)"/>
34</svg>
crates/snowbound/assets/tags/contact.svg deleted-25
......@@ -1,25 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#8aa3c8"/>
5 <stop offset="1" stop-color="#5d7aa6"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#ffffff"/>
9 <stop offset="1" stop-color="#d3e0f2"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#e39a5c"/>
13 <stop offset="1" stop-color="#b8612a"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#e05a30"/>
17 <stop offset="1" stop-color="#b03a1a"/>
18 </linearGradient>
19 </defs>
20 <path d="M1.5 2.5H14.5A1 1 0 0 1 15.5 3.5V12.5A1 1 0 0 1 14.5 13.5H1.5A1 1 0 0 1 0.5 12.5V3.5A1 1 0 0 1 1.5 2.5Z" fill="url(#layer0)"/>
21 <path d="M1.75 3.5H14.25A0.25 0.25 0 0 1 14.5 3.75V12.25A0.25 0.25 0 0 1 14.25 12.5H1.75A0.25 0.25 0 0 1 1.5 12.25V3.75A0.25 0.25 0 0 1 1.75 3.5Z" fill="url(#layer1)"/>
22 <path d="M3.5 6.5A1.75 1.75 0 1 0 7 6.5A1.75 1.75 0 1 0 3.5 6.5Z" fill="url(#layer2)"/>
23 <path d="M2.25 11.75C2.25 9.5 3.6 8.75 5.25 8.75C6.9 8.75 8.25 9.5 8.25 11.75Z" fill="url(#layer3)"/>
24 <path d="M9.5 5.5H13.5V6.75H9.5ZM9.5 8H13.5V9.25H9.5ZM9.5 10.5H12.5V11.75H9.5Z" fill="#4d6d9c"/>
25</svg>
crates/snowbound/assets/tags/highlight.svg deleted-29
......@@ -1,29 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#b23a64"/>
5 <stop offset="1" stop-color="#8c2248"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#7a2a40"/>
9 <stop offset="1" stop-color="#5a1a2c"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#ff9ec0"/>
13 <stop offset="1" stop-color="#e0487c"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#e76b98"/>
17 <stop offset="1" stop-color="#c0305e"/>
18 </linearGradient>
19 <linearGradient id="layer4" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
20 <stop offset="0" stop-color="#ff5f8f"/>
21 <stop offset="1" stop-color="#e0205a"/>
22 </linearGradient>
23 </defs>
24 <path d="M12.31 1.14L14.86 3.69L7.01 11.54L4.46 8.99Z" fill="url(#layer0)"/>
25 <path d="M5.35 7.89L8.11 10.65L4.55 13.04L2.96 11.45Z" fill="url(#layer1)"/>
26 <path d="M12.31 2.13L13.87 3.69L7.01 10.55L5.45 8.99Z" fill="url(#layer2)"/>
27 <path d="M12.31 2.13L13.87 3.69L12.17 5.38L10.62 3.83Z" fill="url(#layer3)"/>
28 <path d="M5.45 8.99L7.01 10.55L4.64 12.14L3.86 11.36Z" fill="url(#layer4)"/>
29</svg>
crates/snowbound/assets/tags/phone.svg deleted-14
......@@ -1,14 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#7f8b99"/>
5 <stop offset="1" stop-color="#56606d"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#f4f7fb"/>
9 <stop offset="1" stop-color="#b3c1d3"/>
10 </linearGradient>
11 </defs>
12 <path d="M12.86 14.11L12.59 14.07L12.31 14.03L12.04 13.98L11.77 13.92L11.49 13.86L11.22 13.79L10.96 13.72L10.69 13.64L10.42 13.56L10.16 13.46L9.9 13.37L9.64 13.27L9.38 13.16L9.13 13.04L8.88 12.92L8.63 12.8L8.38 12.67L8.14 12.53L7.9 12.39L7.66 12.25L7.43 12.09L7.2 11.94L6.97 11.78L6.75 11.61L6.53 11.44L6.31 11.26L6.1 11.08L5.89 10.89L5.69 10.7L5.49 10.51L5.3 10.31L5.11 10.11L4.92 9.9L4.74 9.69L4.56 9.47L4.39 9.25L4.22 9.03L4.06 8.8L3.91 8.57L3.75 8.34L3.61 8.1L3.47 7.86L3.33 7.62L3.2 7.37L3.08 7.12L2.96 6.87L2.84 6.62L2.73 6.36L2.63 6.1L2.54 5.84L2.44 5.58L2.36 5.31L2.28 5.04L2.21 4.78L2.14 4.51L2.08 4.23L2.02 3.96L1.97 3.69L1.93 3.41L1.89 3.14L7.45 2.45L7.47 2.61L7.5 2.76L7.52 2.91L7.55 3.06L7.59 3.21L7.63 3.36L7.67 3.51L7.71 3.66L7.76 3.81L7.81 3.96L7.86 4.1L7.77 4.3L7.44 4.62L6.98 5.02L6.51 5.45L6.15 5.85L5.99 6.17L6.07 6.37L6.18 6.55L6.29 6.73L6.4 6.91L6.52 7.09L6.65 7.26L6.77 7.43L6.9 7.6L7.04 7.76L7.18 7.92L7.32 8.08L7.46 8.24L7.61 8.39L7.76 8.54L7.92 8.68L8.08 8.82L8.24 8.96L8.4 9.1L8.57 9.23L8.74 9.35L8.91 9.48L9.09 9.6L9.27 9.71L9.45 9.82L9.63 9.93L9.83 10.01L10.15 9.85L10.55 9.49L10.98 9.02L11.38 8.56L11.7 8.23L11.9 8.14L12.04 8.19L12.19 8.24L12.34 8.29L12.49 8.33L12.64 8.37L12.79 8.41L12.94 8.45L13.09 8.48L13.24 8.5L13.39 8.53L13.55 8.55Z" fill="url(#layer0)"/>
13 <path d="M12.13 13.15L12.71 13.23L12.45 13.19L12.2 13.14L11.94 13.09L11.69 13.03L11.44 12.97L11.19 12.9L10.94 12.83L10.69 12.75L10.45 12.66L10.2 12.57L9.96 12.48L9.72 12.38L9.48 12.27L9.25 12.16L9.02 12.04L8.79 11.92L8.56 11.8L8.34 11.66L8.12 11.53L7.9 11.39L7.68 11.24L7.47 11.09L7.26 10.93L7.06 10.77L6.86 10.61L6.66 10.44L6.47 10.27L6.28 10.09L6.09 9.91L5.91 9.72L5.73 9.53L5.56 9.34L5.39 9.14L5.23 8.94L5.07 8.74L4.91 8.53L4.76 8.32L4.61 8.1L4.47 7.88L4.34 7.66L4.2 7.44L4.08 7.21L3.96 6.98L3.84 6.75L3.73 6.52L3.62 6.28L3.52 6.04L3.43 5.8L3.34 5.55L3.25 5.31L3.17 5.06L3.1 4.81L3.03 4.56L2.97 4.31L2.91 4.06L2.86 3.8L2.81 3.55L2.77 3.29L2.85 3.87L6.72 3.4L6.63 2.73L6.66 2.9L6.69 3.07L6.72 3.24L6.76 3.41L6.8 3.58L6.85 3.75L6.9 3.91L6.95 4.08L7.01 4.24L6.95 4.07L7.06 3.81L6.87 3.99L6.41 4.38L5.91 4.85L5.43 5.37L5.06 6.14L5.31 6.75L5.45 6.99L5.57 7.19L5.7 7.38L5.83 7.57L5.96 7.76L6.1 7.95L6.24 8.13L6.39 8.31L6.54 8.48L6.69 8.66L6.85 8.82L7.01 8.99L7.18 9.15L7.34 9.31L7.52 9.46L7.69 9.61L7.87 9.76L8.05 9.9L8.24 10.04L8.43 10.17L8.62 10.3L8.81 10.43L9.01 10.55L9.25 10.69L9.86 10.94L10.63 10.57L11.15 10.09L11.62 9.59L12.01 9.13L12.19 8.94L11.93 9.05L11.76 8.99L11.92 9.05L12.09 9.1L12.25 9.15L12.42 9.2L12.59 9.24L12.76 9.28L12.93 9.31L13.1 9.34L13.27 9.37L12.6 9.28Z" fill="url(#layer1)"/>
14</svg>
crates/snowbound/assets/tags/star.svg deleted-14
......@@ -1,14 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#c8a049"/>
5 <stop offset="1" stop-color="#a57a2c"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#fff4c4"/>
9 <stop offset="1" stop-color="#f1c35a"/>
10 </linearGradient>
11 </defs>
12 <path d="M8 1.35L9.94 6.08L15.04 6.46L11.14 9.77L12.35 14.74L8 12.05L3.65 14.74L4.86 9.77L0.96 6.46L6.06 6.08Z" fill="url(#layer0)"/>
13 <path d="M8 3.72L9.32 6.94L12.78 7.2L10.13 9.44L10.96 12.82L8 10.99L5.04 12.82L5.87 9.44L3.22 7.2L6.68 6.94Z" fill="url(#layer1)"/>
14</svg>
crates/snowbound/src/art.rs-5
......@@ -39,10 +39,5 @@ pub const UNDO: &[&str] = art!("icons/undo");
3939pub const ZOOM_IN: &[&str] = art!("icons/zoom-in");
4040pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out");
4141
42pub const TAG_STAR: &[&str] = art!("tags/star");
4342pub const TAG_REMEMBER: &[&str] = art!("tags/remember");
4443pub const TAG_DEFINITION: &[&str] = art!("tags/definition");
45pub const TAG_HIGHLIGHT: &[&str] = art!("tags/highlight");
46pub const TAG_CONTACT: &[&str] = art!("tags/contact");
47pub const TAG_ADDRESS: &[&str] = art!("tags/address");
48pub const TAG_PHONE: &[&str] = art!("tags/phone");
crates/snowbound/src/main.rs+13-13
......@@ -1076,27 +1076,27 @@ impl State {
10761076 });
10771077 });
10781078 divider(ui, "text", theme);
1079 let tags: [(&[&str], Option<NoteTag>); 9] = [
1079 let tags: [(&[&str], NoteTag); 9] = [
10801080 (
10811081 tag_sources(TagIcon::CheckBox { checked: false }),
1082 Some(NoteTag::ToDo),
1082 NoteTag::ToDo,
10831083 ),
1084 (art::TAG_STAR, None),
1085 (tag_sources(TagIcon::Question), Some(NoteTag::Question)),
1086 (art::TAG_REMEMBER, None),
1087 (art::TAG_DEFINITION, None),
1088 (art::TAG_HIGHLIGHT, None),
1089 (art::TAG_CONTACT, None),
1090 (art::TAG_ADDRESS, None),
1091 (art::TAG_PHONE, None),
1084 (tag_sources(TagIcon::Star), NoteTag::Important),
1085 (tag_sources(TagIcon::Question), NoteTag::Question),
1086 (art::TAG_REMEMBER, NoteTag::RememberForLater),
1087 (art::TAG_DEFINITION, NoteTag::Definition),
1088 (tag_sources(TagIcon::Highlight), NoteTag::Highlight),
1089 (tag_sources(TagIcon::Contact), NoteTag::Contact),
1090 (tag_sources(TagIcon::Address), NoteTag::Address),
1091 (tag_sources(TagIcon::Phone), NoteTag::PhoneNumber),
10921092 ];
10931093 group(ui, "tags", |ui| {
10941094 for (index, tags) in tags.chunks(5).enumerate() {
10951095 row(ui, index, |ui| {
10961096 for (column, (icon, tag)) in tags.iter().enumerate() {
1097 let lit = tag.is_some_and(|tag| state.tags.contains(&tag));
1097 let lit = state.tags.contains(tag);
10981098 if ui::shell::tool_button(ui, column, icon, [1.0; 4], lit).clicked {
1099 command = tag.map(Formatting::Tag);
1099 command = Some(Formatting::Tag(*tag));
11001100 }
11011101 }
11021102 if index == 1 {
......@@ -1448,7 +1448,7 @@ impl State {
14481448 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,
14491449 Command::Page(Request::Paste) => {
14501450 let text = self.clipboard.get_text()?;
1451 let response = self.view.commit_text(text)?;
1451 let response = self.view.paste(&text)?;
14521452 self.respond(response);
14531453 }
14541454 Command::Page(Request::CharacterPalette) => macos::show_character_palette(),