| ... | ... | @@ -0,0 +1,668 @@ |
| 1 | //! Markdown shortcuts: Markdown typed at a paragraph's start or around text becomes the |
| 2 | //! OneNote formatting it stands for, as one undo step after the typing, which Undo takes back |
| 3 | //! to the characters typed, as Word's AutoFormat As You Type does. |
| 4 | |
| 5 | use super::format::{ListStyle, NoteTag, list_definition, styled, time32}; |
| 6 | use super::*; |
| 7 | |
| 8 | /// What a marker typed at a paragraph's start makes of it. |
| 9 | #[derive(Debug, PartialEq)] |
| 10 | enum Block { |
| 11 | /// A style of the Styles gallery, by stored name. |
| 12 | Style(String), |
| 13 | /// A list, its count restarting at the number given. |
| 14 | List(ListStyle, Option<u32>), |
| 15 | /// A To Do check box, checked or not. |
| 16 | ToDo(bool), |
| 17 | } |
| 18 | |
| 19 | impl Block { |
| 20 | /// The lists are those OneNote 2010's AutoFormat makes of the same markers, but for `>`, |
| 21 | /// which it makes an arrow bullet. |
| 22 | fn of(marker: &str) -> Option<Self> { |
| 23 | Some(match marker { |
| 24 | "*" => Self::List(ListStyle::BULLET, None), |
| 25 | "-" => Self::List(ListStyle::Bullet(25), None), |
| 26 | "a." => Self::List(ListStyle::Number(2), None), |
| 27 | ">" => Self::Style("blockquote".into()), |
| 28 | "[ ]" => Self::ToDo(false), |
| 29 | "[x]" | "[X]" => Self::ToDo(true), |
| 30 | _ if marker.len() <= 6 && !marker.is_empty() && marker.bytes().all(|b| b == b'#') => { |
| 31 | Self::Style(format!("h{}", marker.len())) |
| 32 | } |
| 33 | _ => { |
| 34 | let (digits, style) = match marker.as_bytes().last()? { |
| 35 | b'.' => (&marker[..marker.len() - 1], ListStyle::NUMBER), |
| 36 | b')' => (&marker[..marker.len() - 1], ListStyle::Number(6)), |
| 37 | _ => return None, |
| 38 | }; |
| 39 | if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { |
| 40 | return None; |
| 41 | } |
| 42 | let number = digits.parse::<u32>().ok()?; |
| 43 | Self::List(style, (number != 1).then_some(number)) |
| 44 | } |
| 45 | }) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | #[derive(Clone, Copy)] |
| 50 | enum Inline { |
| 51 | Bold, |
| 52 | Italic, |
| 53 | Strike, |
| 54 | Code, |
| 55 | } |
| 56 | |
| 57 | /// Markers typed around text, a pair before its half. |
| 58 | const INLINE: [(&str, Inline); 5] = [ |
| 59 | ("**", Inline::Bold), |
| 60 | ("~~", Inline::Strike), |
| 61 | ("*", Inline::Italic), |
| 62 | ("_", Inline::Italic), |
| 63 | ("`", Inline::Code), |
| 64 | ]; |
| 65 | |
| 66 | /// Whether no character of bytes `range` belongs to a link, field or equation, or is set in |
| 67 | /// the Code style's font. |
| 68 | fn plain(text: &Paragraph, range: Range<usize>, code_font: Option<&str>) -> bool { |
| 69 | let mut start = 0; |
| 70 | text.spans().iter().all(|span| { |
| 71 | let overlaps = start < range.end && span.end > range.start; |
| 72 | start = span.end; |
| 73 | let format = &span.format; |
| 74 | !overlaps |
| 75 | || ![ |
| 76 | format.hidden, |
| 77 | format.hyperlink, |
| 78 | format.math, |
| 79 | format.embedded_object, |
| 80 | ] |
| 81 | .contains(&Some(true)) |
| 82 | && (code_font.is_none() || format.font.as_deref() != code_font) |
| 83 | }) |
| 84 | } |
| 85 | |
| 86 | /// `text` without bytes `markers`, what lies between them changed by `change`; a paragraph |
| 87 | /// left empty keeps the format of the first marker. |
| 88 | fn unmarked( |
| 89 | text: &Paragraph, |
| 90 | markers: [Range<usize>; 2], |
| 91 | change: impl Fn(&mut Format), |
| 92 | ) -> Paragraph { |
| 93 | let [open, close] = markers; |
| 94 | let kept = [ |
| 95 | (0..open.start, false), |
| 96 | (open.end..close.start, true), |
| 97 | (close.end..text.text().len(), false), |
| 98 | ]; |
| 99 | let mut runs = Vec::new(); |
| 100 | let mut start = 0; |
| 101 | for span in text.spans() { |
| 102 | for (range, inside) in &kept { |
| 103 | let [from, to] = [range.start, range.end].map(|at| at.clamp(start, span.end)); |
| 104 | if from < to { |
| 105 | let mut format = span.format.clone(); |
| 106 | if *inside { |
| 107 | change(&mut format); |
| 108 | } |
| 109 | runs.push((text.text()[from..to].to_owned(), format)); |
| 110 | } |
| 111 | } |
| 112 | start = span.end; |
| 113 | } |
| 114 | if runs.is_empty() { |
| 115 | let first = text.spans().partition_point(|span| span.end <= open.start); |
| 116 | let format = text.spans()[first.min(text.spans().len() - 1)] |
| 117 | .format |
| 118 | .clone(); |
| 119 | return Paragraph::new(String::new(), format); |
| 120 | } |
| 121 | Paragraph::from_runs(runs) |
| 122 | } |
| 123 | |
| 124 | impl CanvasEditor { |
| 125 | /// The Styles gallery's style `name`: the page's theme's, else OneNote 2010's. |
| 126 | fn gallery(&self, name: &str) -> Option<Definition> { |
| 127 | self.styles |
| 128 | .get(name) |
| 129 | .or_else(|| self.markdown.as_ref()?.get(name)) |
| 130 | .cloned() |
| 131 | } |
| 132 | |
| 133 | /// Applies the Markdown shortcut that `typed`, just typed at the caret, completes, as its |
| 134 | /// own undo step; a marker at the paragraph's start can then be taken back by Backspace. |
| 135 | pub(super) fn markdown( |
| 136 | &mut self, |
| 137 | engine: &mut TextEngine, |
| 138 | typed: &str, |
| 139 | ) -> Result<(), EditorError> { |
| 140 | let outline = self.active_outline(); |
| 141 | let [anchor, focus] = outline.selection.positions; |
| 142 | if self.markdown.is_none() |
| 143 | || !matches!(typed, " " | "*" | "_" | "~" | "`") |
| 144 | || outline.title |
| 145 | || anchor != focus |
| 146 | || self.page_selected() |
| 147 | { |
| 148 | return Ok(()); |
| 149 | } |
| 150 | let (container, index, node) = outline |
| 151 | .document |
| 152 | .leaf(focus.paragraph) |
| 153 | .ok_or(EditError::InvalidRange)?; |
| 154 | let id = outline.id; |
| 155 | let mut node = node.clone(); |
| 156 | if node |
| 157 | .style |
| 158 | .and_then(|style| self.definitions.get(&style)) |
| 159 | .is_some_and(|style| matches!(&style.kind, Kind::Style { name: Some(name), .. } if name == "code")) |
| 160 | { |
| 161 | return Ok(()); |
| 162 | } |
| 163 | let text = node.text().ok_or(EditError::InvalidRange)?.text.clone(); |
| 164 | let caret = text.byte_offset(focus.offset)?; |
| 165 | let code_font = self.gallery("code").and_then(|code| code.format.font); |
| 166 | let code_font = code_font.as_deref(); |
| 167 | let (replacement, at, block) = if typed == " " { |
| 168 | let Some(block) = text.text()[..caret].strip_suffix(' ').and_then(Block::of) else { |
| 169 | return Ok(()); |
| 170 | }; |
| 171 | if !plain(&text, 0..caret, code_font) { |
| 172 | return Ok(()); |
| 173 | } |
| 174 | let rest = unmarked(&text, [0..caret, caret..caret], |_| {}); |
| 175 | (rest, 0, Some(block)) |
| 176 | } else { |
| 177 | let Some((rest, at)) = inline(&text, caret, code_font) else { |
| 178 | return Ok(()); |
| 179 | }; |
| 180 | (rest, at, None) |
| 181 | }; |
| 182 | let typing = text.format_at(focus.offset)?.clone(); |
| 183 | let text = &mut node.text_mut().unwrap().text; |
| 184 | *text = replacement; |
| 185 | let offset = text.utf16_offset(at)?; |
| 186 | let line = block.is_some(); |
| 187 | match block { |
| 188 | Some(Block::Style(name)) => { |
| 189 | let Some(definition) = self.gallery(&name) else { |
| 190 | return Ok(()); |
| 191 | }; |
| 192 | let old = self.style_format(node.style)?; |
| 193 | node.style = Some(self.define_style(&definition)?); |
| 194 | let text = &mut node.text_mut().unwrap().text; |
| 195 | *text = styled(text, &old, &definition); |
| 196 | } |
| 197 | Some(Block::List(style, restart)) => { |
| 198 | let format = &node.text().unwrap().text.spans()[0].format; |
| 199 | let mut definition = list_definition(style, format); |
| 200 | if let Kind::List { restart: count, .. } = &mut definition.kind { |
| 201 | *count = restart; |
| 202 | } |
| 203 | let list = onestore::page::text::new_id()?; |
| 204 | self.definitions.insert(list, definition); |
| 205 | node.lists = vec![list]; |
| 206 | } |
| 207 | // A check box takes a list's place, as To Do List does. |
| 208 | Some(Block::ToDo(checked)) => { |
| 209 | node.lists.clear(); |
| 210 | let tag = &NoteTag::defaults()[0]; |
| 211 | let definition = tag.definition(0); |
| 212 | let tag_id = self.define_tag(&definition)?; |
| 213 | let ParagraphContent::Text(text) = &mut node.content else { |
| 214 | unreachable!() |
| 215 | }; |
| 216 | self.retag( |
| 217 | [&mut node.tags, &mut text.tags], |
| 218 | &definition.kind, |
| 219 | Some((tag_id, tag.shape, time32())), |
| 220 | ); |
| 221 | if checked { |
| 222 | self.toggle_checks(node.tags.iter_mut().chain(&mut text.tags).collect()); |
| 223 | } |
| 224 | } |
| 225 | None => {} |
| 226 | } |
| 227 | let caret = TextPosition { |
| 228 | paragraph: focus.paragraph, |
| 229 | offset, |
| 230 | }; |
| 231 | self.commit( |
| 232 | engine, |
| 233 | DocumentEdit { |
| 234 | columns: BTreeMap::new(), |
| 235 | container, |
| 236 | range: index..index + 1, |
| 237 | replacement: vec![node], |
| 238 | }, |
| 239 | Selection { |
| 240 | positions: [caret; 2], |
| 241 | affinities: [Affinity::Upstream; 2], |
| 242 | }, |
| 243 | )?; |
| 244 | self.typing = None; |
| 245 | if line { |
| 246 | self.formatted = Some((id, caret, self.undo.len())); |
| 247 | } else { |
| 248 | // Typing goes on in the format the closing marker was typed in. |
| 249 | self.pending = Some((id, caret, typing)); |
| 250 | } |
| 251 | Ok(()) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | /// `text` with the span whose closing marker ends at byte `caret` formatted and its markers |
| 256 | /// gone, and where the caret goes then; none where no span closes there, or it would |
| 257 | /// close inside a word, a URL, a link, an equation or code. |
| 258 | fn inline(text: &Paragraph, caret: usize, code_font: Option<&str>) -> Option<(Paragraph, usize)> { |
| 259 | let source = text.text(); |
| 260 | let before = &source[..caret]; |
| 261 | let (marker, inline) = INLINE |
| 262 | .into_iter() |
| 263 | .find(|(marker, _)| before.ends_with(marker))?; |
| 264 | let edge = marker.chars().next()?; |
| 265 | let close = caret - marker.len(); |
| 266 | let open = before[..close].rfind(marker)?; |
| 267 | let content = &before[open + marker.len()..close]; |
| 268 | let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == edge); |
| 269 | if content.is_empty() |
| 270 | || content.starts_with(char::is_whitespace) |
| 271 | || content.ends_with(char::is_whitespace) |
| 272 | || content.starts_with(edge) |
| 273 | || content.ends_with(edge) |
| 274 | || word(before[..open].chars().next_back()) |
| 275 | || source[caret..].chars().next().is_some_and(char::is_alphanumeric) |
| 276 | || !plain(text, open..caret, code_font) |
| 277 | // An odd backtick before the span opens code around it. |
| 278 | || !matches!(inline, Inline::Code) && before[..open].matches('`').count() % 2 == 1 |
| 279 | { |
| 280 | return None; |
| 281 | } |
| 282 | let token = before[..open] |
| 283 | .rfind(char::is_whitespace) |
| 284 | .map_or(0, |space| space + 1); |
| 285 | let url = &before[token..]; |
| 286 | if url.contains("://") || url.starts_with("www.") { |
| 287 | return None; |
| 288 | } |
| 289 | let font = code_font.unwrap_or("Consolas").to_owned(); |
| 290 | let rest = unmarked( |
| 291 | text, |
| 292 | [open..open + marker.len(), close..caret], |
| 293 | |format| match inline { |
| 294 | Inline::Bold => format.bold = Some(true), |
| 295 | Inline::Italic => format.italic = Some(true), |
| 296 | Inline::Strike => format.strike = Some(true), |
| 297 | Inline::Code => format.font = Some(font.clone()), |
| 298 | }, |
| 299 | ); |
| 300 | Some((rest, caret - 2 * marker.len())) |
| 301 | } |
| 302 | |
| 303 | #[cfg(test)] |
| 304 | mod tests { |
| 305 | use super::*; |
| 306 | |
| 307 | #[test] |
| 308 | fn markers_name_their_blocks() { |
| 309 | assert_eq!(Block::of("###"), Some(Block::Style("h3".into()))); |
| 310 | assert_eq!(Block::of("#######"), None); |
| 311 | assert_eq!( |
| 312 | Block::of("2."), |
| 313 | Some(Block::List(ListStyle::NUMBER, Some(2))) |
| 314 | ); |
| 315 | assert_eq!( |
| 316 | Block::of("1)"), |
| 317 | Some(Block::List(ListStyle::Number(6), None)) |
| 318 | ); |
| 319 | assert_eq!(Block::of("."), None); |
| 320 | assert_eq!(Block::of("1a."), None); |
| 321 | assert_eq!(Block::of("[x]"), Some(Block::ToDo(true))); |
| 322 | } |
| 323 | |
| 324 | fn calibri() -> Format { |
| 325 | Format { |
| 326 | font: Some("Calibri".into()), |
| 327 | font_size: Some(11.0), |
| 328 | ..Format::default() |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | fn style(name: &str, font: &str) -> Definition { |
| 333 | Definition { |
| 334 | kind: Kind::Style { |
| 335 | name: Some(name.into()), |
| 336 | next: name.starts_with('h').then(|| "p".into()), |
| 337 | }, |
| 338 | format: Format { |
| 339 | bold: Some(name.starts_with('h')), |
| 340 | font: Some(font.into()), |
| 341 | font_size: Some(14.0), |
| 342 | ..Format::default() |
| 343 | }, |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | /// An editor with Markdown shortcuts on, its one paragraph holding `text`, the caret |
| 348 | /// at its end. |
| 349 | fn opened(engine: &mut TextEngine, text: &str) -> CanvasEditor { |
| 350 | let paragraph = Paragraph::new(text.into(), calibri()); |
| 351 | let mut editor = |
| 352 | CanvasEditor::new(engine, TextDocument::new(vec![paragraph]).unwrap(), 300.0).unwrap(); |
| 353 | let gallery = ["h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "p"] |
| 354 | .map(|name| (name.to_owned(), style(name, "Calibri"))); |
| 355 | editor.markdown = Some( |
| 356 | gallery |
| 357 | .into_iter() |
| 358 | .chain([("code".to_owned(), style("code", "Consolas"))]) |
| 359 | .collect(), |
| 360 | ); |
| 361 | let end = text.encode_utf16().count() as u32; |
| 362 | editor |
| 363 | .select( |
| 364 | [TextPosition { |
| 365 | paragraph: 0, |
| 366 | offset: end, |
| 367 | }; 2] |
| 368 | .into(), |
| 369 | ) |
| 370 | .unwrap(); |
| 371 | editor |
| 372 | } |
| 373 | |
| 374 | fn typed(engine: &mut TextEngine, editor: &mut CanvasEditor, text: &str) { |
| 375 | for character in text.chars() { |
| 376 | editor |
| 377 | .insert(engine, character.encode_utf8(&mut [0; 4])) |
| 378 | .unwrap(); |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | fn text(editor: &CanvasEditor) -> String { |
| 383 | editor |
| 384 | .active_outline() |
| 385 | .document |
| 386 | .paragraphs() |
| 387 | .next() |
| 388 | .unwrap() |
| 389 | .text() |
| 390 | .to_owned() |
| 391 | } |
| 392 | |
| 393 | /// The paragraph's runs, with whether each is bold, italic, struck and in Consolas. |
| 394 | fn runs(editor: &CanvasEditor) -> Vec<(String, [bool; 4])> { |
| 395 | let paragraph = editor |
| 396 | .active_outline() |
| 397 | .document |
| 398 | .paragraphs() |
| 399 | .next() |
| 400 | .unwrap(); |
| 401 | let mut start = 0; |
| 402 | paragraph |
| 403 | .spans() |
| 404 | .iter() |
| 405 | .map(|span| { |
| 406 | let text = paragraph.text()[start..span.end].to_owned(); |
| 407 | start = span.end; |
| 408 | let format = &span.format; |
| 409 | let on = |value: Option<bool>| value == Some(true); |
| 410 | let code = format.font.as_deref() == Some("Consolas"); |
| 411 | ( |
| 412 | text, |
| 413 | [on(format.bold), on(format.italic), on(format.strike), code], |
| 414 | ) |
| 415 | }) |
| 416 | .collect() |
| 417 | } |
| 418 | |
| 419 | #[test] |
| 420 | fn hashes_make_headings_that_undo_and_backspace_take_back_to_the_typed_text() { |
| 421 | let mut engine = TextEngine::default(); |
| 422 | for level in 1..=6 { |
| 423 | let mut editor = opened(&mut engine, ""); |
| 424 | typed(&mut engine, &mut editor, &format!("{} ", "#".repeat(level))); |
| 425 | assert_eq!(text(&editor), ""); |
| 426 | let state = editor.format_state().unwrap(); |
| 427 | assert_eq!(state.style, Some(format!("h{level}"))); |
| 428 | typed(&mut engine, &mut editor, "Title"); |
| 429 | assert_eq!(text(&editor), "Title"); |
| 430 | assert_eq!( |
| 431 | runs(&editor), |
| 432 | [("Title".into(), [true, false, false, false])] |
| 433 | ); |
| 434 | // Undo takes the typing, then the heading back to the marker. |
| 435 | editor.undo(&mut engine).unwrap(); |
| 436 | assert_eq!(text(&editor), ""); |
| 437 | editor.undo(&mut engine).unwrap(); |
| 438 | assert_eq!(text(&editor), format!("{} ", "#".repeat(level))); |
| 439 | assert_eq!(editor.format_state().unwrap().style, None); |
| 440 | } |
| 441 | let mut editor = opened(&mut engine, ""); |
| 442 | typed(&mut engine, &mut editor, "## "); |
| 443 | editor.delete(&mut engine, true).unwrap(); |
| 444 | assert_eq!(text(&editor), "## "); |
| 445 | assert_eq!(editor.format_state().unwrap().style, None); |
| 446 | // A second Backspace deletes as ever. |
| 447 | editor.delete(&mut engine, true).unwrap(); |
| 448 | assert_eq!(text(&editor), "##"); |
| 449 | } |
| 450 | |
| 451 | #[test] |
| 452 | fn markers_start_lists_to_dos_and_quotes_as_onenote_s_autoformat_does() { |
| 453 | let mut engine = TextEngine::default(); |
| 454 | for (marker, list) in [ |
| 455 | ("* ", ListStyle::BULLET), |
| 456 | ("- ", ListStyle::Bullet(25)), |
| 457 | ("1. ", ListStyle::NUMBER), |
| 458 | ("1) ", ListStyle::Number(6)), |
| 459 | ("a. ", ListStyle::Number(2)), |
| 460 | ] { |
| 461 | let mut editor = opened(&mut engine, ""); |
| 462 | typed(&mut engine, &mut editor, marker); |
| 463 | assert_eq!(text(&editor), "", "{marker}"); |
| 464 | assert_eq!(editor.format_state().unwrap().list, Some(list), "{marker}"); |
| 465 | editor.delete(&mut engine, true).unwrap(); |
| 466 | assert_eq!(text(&editor), marker); |
| 467 | assert_eq!(editor.format_state().unwrap().list, None); |
| 468 | } |
| 469 | let mut editor = opened(&mut engine, ""); |
| 470 | typed(&mut engine, &mut editor, "3. "); |
| 471 | let node = &editor.active_outline().document.nodes()[0]; |
| 472 | assert!(matches!( |
| 473 | editor.definitions[&node.lists[0]].kind, |
| 474 | Kind::List { |
| 475 | restart: Some(3), |
| 476 | .. |
| 477 | } |
| 478 | )); |
| 479 | for (marker, checked) in [("[ ] ", false), ("[x] ", true)] { |
| 480 | let mut editor = opened(&mut engine, ""); |
| 481 | typed(&mut engine, &mut editor, marker); |
| 482 | assert_eq!(text(&editor), ""); |
| 483 | let state = editor.format_state().unwrap(); |
| 484 | assert_eq!(state.tags[0].0.label, "To Do"); |
| 485 | let tag = &editor.active_outline().document.nodes()[0] |
| 486 | .text() |
| 487 | .unwrap() |
| 488 | .tags[0]; |
| 489 | assert_eq!(tag.status & 1 != 0, checked); |
| 490 | } |
| 491 | let mut editor = opened(&mut engine, ""); |
| 492 | typed(&mut engine, &mut editor, "> "); |
| 493 | assert_eq!( |
| 494 | editor.format_state().unwrap().style.as_deref(), |
| 495 | Some("blockquote") |
| 496 | ); |
| 497 | // A marker before text already there formats it too. |
| 498 | let mut editor = opened(&mut engine, "words"); |
| 499 | editor |
| 500 | .select( |
| 501 | [TextPosition { |
| 502 | paragraph: 0, |
| 503 | offset: 0, |
| 504 | }; 2] |
| 505 | .into(), |
| 506 | ) |
| 507 | .unwrap(); |
| 508 | typed(&mut engine, &mut editor, "# "); |
| 509 | assert_eq!(text(&editor), "words"); |
| 510 | assert_eq!(editor.format_state().unwrap().style.as_deref(), Some("h1")); |
| 511 | } |
| 512 | |
| 513 | #[test] |
| 514 | fn closing_markers_format_the_text_between_and_undo_returns_the_markers() { |
| 515 | let mut engine = TextEngine::default(); |
| 516 | for (input, format) in [ |
| 517 | ("**bold**", [true, false, false, false]), |
| 518 | ("*it*", [false, true, false, false]), |
| 519 | ("_it_", [false, true, false, false]), |
| 520 | ("~~gone~~", [false, false, true, false]), |
| 521 | ("`a*b*c`", [false, false, false, true]), |
| 522 | ] { |
| 523 | let mut editor = opened(&mut engine, "say "); |
| 524 | typed(&mut engine, &mut editor, input); |
| 525 | let inner = input.trim_matches(['*', '_', '~', '`']); |
| 526 | assert_eq!( |
| 527 | runs(&editor), |
| 528 | [("say ".into(), [false; 4]), (inner.into(), format),], |
| 529 | "{input}" |
| 530 | ); |
| 531 | // What is typed next is plain. |
| 532 | typed(&mut engine, &mut editor, " on"); |
| 533 | assert_eq!(runs(&editor).last().unwrap(), &(" on".into(), [false; 4])); |
| 534 | editor.undo(&mut engine).unwrap(); |
| 535 | editor.undo(&mut engine).unwrap(); |
| 536 | assert_eq!(text(&editor), format!("say {input}"), "{input}"); |
| 537 | } |
| 538 | } |
| 539 | |
| 540 | #[test] |
| 541 | fn markers_inside_words_urls_code_and_titles_or_with_shortcuts_off_stay_typed() { |
| 542 | let mut engine = TextEngine::default(); |
| 543 | for input in [ |
| 544 | "snake_case_name", |
| 545 | "2*3*4", |
| 546 | "a * b *", |
| 547 | "http://example.com/_a_", |
| 548 | "www.example.com/*a*", |
| 549 | "`a *b*", |
| 550 | "**", |
| 551 | "#tag ", |
| 552 | "x # ", |
| 553 | "####### ", |
| 554 | ] { |
| 555 | let mut editor = opened(&mut engine, ""); |
| 556 | typed(&mut engine, &mut editor, input); |
| 557 | assert_eq!(text(&editor), input); |
| 558 | assert_eq!(runs(&editor), [(input.into(), [false; 4])], "{input}"); |
| 559 | } |
| 560 | // A closing marker typed before a word's letters leaves them unformatted. |
| 561 | let mut editor = opened(&mut engine, "*a b"); |
| 562 | editor |
| 563 | .select( |
| 564 | [TextPosition { |
| 565 | paragraph: 0, |
| 566 | offset: 3, |
| 567 | }; 2] |
| 568 | .into(), |
| 569 | ) |
| 570 | .unwrap(); |
| 571 | typed(&mut engine, &mut editor, "*"); |
| 572 | assert_eq!(text(&editor), "*a *b"); |
| 573 | let mut editor = opened(&mut engine, ""); |
| 574 | editor.markdown = None; |
| 575 | typed(&mut engine, &mut editor, "# **a** "); |
| 576 | assert_eq!(text(&editor), "# **a** "); |
| 577 | // Code keeps what it holds. |
| 578 | let mut editor = opened(&mut engine, ""); |
| 579 | let code = editor.gallery("code").unwrap(); |
| 580 | editor.format(&mut engine, Formatting::Style(code)).unwrap(); |
| 581 | typed(&mut engine, &mut editor, "# *a* "); |
| 582 | assert_eq!(text(&editor), "# *a* "); |
| 583 | } |
| 584 | |
| 585 | /// Every shortcut stores ordinary OneNote formatting that reads back as itself. |
| 586 | /// `SNOWBOUND_MARKDOWN_EXPORT` names a new directory receiving the notebook, for a cold |
| 587 | /// open in OneNote. |
| 588 | #[test] |
| 589 | fn shortcuts_write_as_onenote_formatting() { |
| 590 | use onestore::{RevisionIndex, Store, document::Document}; |
| 591 | const NOTEBOOK: &str = "../../corpus/paragraph-edit/before/notebook"; |
| 592 | let section = std::fs::read(format!("{NOTEBOOK}/synthetic.one")).unwrap(); |
| 593 | let page = |bytes: &[u8]| { |
| 594 | let store = Store::parse(bytes).unwrap(); |
| 595 | let index = RevisionIndex::parse(&store).unwrap(); |
| 596 | let document = Document::parse(&index).unwrap(); |
| 597 | document |
| 598 | .pages() |
| 599 | .unwrap() |
| 600 | .into_iter() |
| 601 | .map(|(space, _)| (space, Page::from_space(&document, space).unwrap())) |
| 602 | .find(|(_, page)| page.title == "Split middle") |
| 603 | .unwrap() |
| 604 | }; |
| 605 | let (space, source) = page(&section); |
| 606 | let mut engine = TextEngine::default(); |
| 607 | let mut editor = CanvasEditor::from_page(source, &mut engine).unwrap(); |
| 608 | editor.markdown = opened(&mut engine, "").markdown; |
| 609 | let body = editor |
| 610 | .outlines() |
| 611 | .iter() |
| 612 | .find(|outline| !outline.title) |
| 613 | .unwrap() |
| 614 | .id; |
| 615 | editor.focus_outline(body).unwrap(); |
| 616 | editor |
| 617 | .move_selection(&mut engine, Movement::DocumentEnd, false) |
| 618 | .unwrap(); |
| 619 | for line in [ |
| 620 | "Plain **bold** *italic* _also_ ~~struck~~ `code` end", |
| 621 | "# Heading one", |
| 622 | "## Heading two", |
| 623 | "### Heading three", |
| 624 | "#### Heading four", |
| 625 | "##### Heading five", |
| 626 | "###### Heading six", |
| 627 | "* Star", |
| 628 | "- Dash", |
| 629 | "1. One", |
| 630 | "2) Two", |
| 631 | "a. Alpha", |
| 632 | "3. Three", |
| 633 | "[ ] To do", |
| 634 | "[x] Done", |
| 635 | "[X] Done too", |
| 636 | "> Quoted", |
| 637 | ] { |
| 638 | editor.enter(&mut engine, false).unwrap(); |
| 639 | typed(&mut engine, &mut editor, line); |
| 640 | } |
| 641 | let edited = editor.page().unwrap(); |
| 642 | let written = super::super::ops::saved(&section, space, &mut editor); |
| 643 | let (_, reread) = page(&written); |
| 644 | let settled = |mut page: Page| { |
| 645 | for object in &mut page.objects { |
| 646 | if let PageObject::Outline(outline) = object { |
| 647 | for text in outline.paragraphs.iter_mut().filter_map(|p| p.text_mut()) { |
| 648 | for tag in &mut text.tags { |
| 649 | tag.extra_set = 0; |
| 650 | } |
| 651 | } |
| 652 | } |
| 653 | } |
| 654 | page |
| 655 | }; |
| 656 | assert_eq!(settled(reread).objects, settled(edited).objects); |
| 657 | if let Some(directory) = std::env::var_os("SNOWBOUND_MARKDOWN_EXPORT") { |
| 658 | let directory = std::path::Path::new(&directory); |
| 659 | std::fs::create_dir(directory).unwrap(); |
| 660 | std::fs::write(directory.join("synthetic.one"), &written).unwrap(); |
| 661 | std::fs::copy( |
| 662 | format!("{NOTEBOOK}/Open Notebook.onetoc2"), |
| 663 | directory.join("Open Notebook.onetoc2"), |
| 664 | ) |
| 665 | .unwrap(); |
| 666 | } |
| 667 | } |
| 668 | } |