| author | |
| committer | |
| log | e9e724551a1f4aec871dbe9a3ce15eb97c1fe1a8 |
| tree | 0e2cd539ce9218876196a6f7c85453ef05a45c3b |
| parent | 1de0431d8bcf7c9e558be7762f0996e76eb899b2 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
`# ` to `###### ` give Heading 1 to 6, `> ` Quote, `[ ] `, `[x] ` and `[X] ` a
To Do check box, and `* `, `- `, `1. `, `1) ` and `a. ` the lists OneNote
2010's own AutoFormat makes of them: bullets 2 and 25, numbers `##.` and
`##)`, the lettered sequence, and a count restarting at a typed number. Its
AutoFormat makes `> ` an arrow bullet; here it is the Quote style.
`**bold**`, `*italic*`, `_italic_`, `~~strike~~` and `` `code` `` format the
text between, but not inside words, URLs, links, equations or code. Each is
one edit after the typing, which Undo takes back to the characters typed;
Backspace does too after a paragraph marker.
A page written with every trigger and the built-in theme cold-opens in OneNote
2010 without complaint, its h1 to h6 and blockquote quick styles, lists, To Do
states and inline formatting reading back as written; its list XML matches
what OneNote's AutoFormat wrote for the same markers.
"Markdown Shortcuts", on by default, is in the toolbar's More View Options,
the command palette, Options > Editing and the iOS More menu, and saved in
the settings.
Assisted-by: claude-opus-5.513 files changed, 800 insertions(+), 39 deletions(-)
apps/ios/Snowbound/App.swift+20-2| ... | @@ -71,8 +71,8 @@ enum Appearance { | ... | @@ -71,8 +71,8 @@ enum Appearance { |
| 71 | } | 71 | } |
| 72 | } | 72 | } |
| 73 | 73 | ||
| 74 | /// OneNote's Snap To Grid and Default font, kept between launches, which the pages opened | 74 | /// OneNote's Snap To Grid and Default font, and Markdown Shortcuts, kept between launches, |
| 75 | /// from then on take. | 75 | /// which the pages opened from then on take. |
| 76 | enum Editing { | 76 | enum Editing { |
| 77 | /// OneNote 2010's Default font sizes, in points. | 77 | /// OneNote 2010's Default font sizes, in points. |
| 78 | static let sizes: [Float] = [8, 9, 9.5, 10, 10.5, 11, 11.5, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72] | 78 | static let sizes: [Float] = [8, 9, 9.5, 10, 10.5, 11, 11.5, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72] |
| ... | @@ -86,6 +86,14 @@ enum Editing { | ... | @@ -86,6 +86,14 @@ enum Editing { |
| 86 | } | 86 | } |
| 87 | } | 87 | } |
| 88 | 88 | ||
| 89 | static var markdownShortcuts: Bool { | ||
| 90 | get { !UserDefaults.standard.bool(forKey: "ignoreMarkdown") } | ||
| 91 | set { | ||
| 92 | UserDefaults.standard.set(!newValue, forKey: "ignoreMarkdown") | ||
| 93 | apply() | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 89 | static var face: String { | 97 | static var face: String { |
| 90 | get { UserDefaults.standard.string(forKey: "defaultFontFace") ?? "Calibri" } | 98 | get { UserDefaults.standard.string(forKey: "defaultFontFace") ?? "Calibri" } |
| 91 | set { | 99 | set { |
| ... | @@ -105,6 +113,16 @@ enum Editing { | ... | @@ -105,6 +113,16 @@ enum Editing { |
| 105 | static func apply() { | 113 | static func apply() { |
| 106 | sb_set_snap_to_grid(snapToGrid) | 114 | sb_set_snap_to_grid(snapToGrid) |
| 107 | sb_set_default_font(face, size) | 115 | sb_set_default_font(face, size) |
| 116 | sb_set_markdown_shortcuts(markdownShortcuts) | ||
| 117 | } | ||
| 118 | |||
| 119 | /// Markdown Shortcuts: typed Markdown becomes formatting. | ||
| 120 | static func markdownAction() -> UIAction { | ||
| 121 | let action = UIAction(title: "Markdown Shortcuts", image: UIImage(systemName: "number")) { _ in | ||
| 122 | markdownShortcuts.toggle() | ||
| 123 | } | ||
| 124 | action.state = markdownShortcuts ? .on : .off | ||
| 125 | return action | ||
| 108 | } | 126 | } |
| 109 | 127 | ||
| 110 | /// Options' Default font: the face and size new text takes. | 128 | /// Options' Default font: the face and size new text takes. |
apps/ios/Snowbound/Lists.swift+1| ... | @@ -131,6 +131,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -131,6 +131,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 131 | }) | 131 | }) |
| 132 | actions.append(Appearance.menu()) | 132 | actions.append(Appearance.menu()) |
| 133 | actions.append(Editing.fontMenu()) | 133 | actions.append(Editing.fontMenu()) |
| 134 | actions.append(Editing.markdownAction()) | ||
| 134 | provide(actions) | 135 | provide(actions) |
| 135 | } | 136 | } |
| 136 | ]) | 137 | ]) |
crates/canvas/src/editor.rs+21-4| ... | @@ -32,6 +32,7 @@ mod format; | ... | @@ -32,6 +32,7 @@ mod format; |
| 32 | mod html; | 32 | mod html; |
| 33 | mod ink; | 33 | mod ink; |
| 34 | mod link; | 34 | mod link; |
| 35 | mod markdown; | ||
| 35 | mod ops; | 36 | mod ops; |
| 36 | pub(crate) mod page; | 37 | pub(crate) mod page; |
| 37 | mod recording; | 38 | mod recording; |
| ... | @@ -179,6 +180,12 @@ pub struct CanvasEditor { | ... | @@ -179,6 +180,12 @@ pub struct CanvasEditor { |
| 179 | /// The paragraph styles the page's theme gives, by stored name: new text takes its `p`, | 180 | /// The paragraph styles the page's theme gives, by stored name: new text takes its `p`, |
| 180 | /// and Enter a NextStyle the page lacks from here. | 181 | /// and Enter a NextStyle the page lacks from here. |
| 181 | pub styles: BTreeMap<String, Definition>, | 182 | pub styles: BTreeMap<String, Definition>, |
| 183 | /// OneNote 2010's gallery styles, which Markdown shortcuts apply where the page's theme | ||
| 184 | /// gives none; none turns the shortcuts off. | ||
| 185 | pub markdown: Option<BTreeMap<String, Definition>>, | ||
| 186 | /// The outline, caret and undo depth a paragraph's Markdown marker left, where Backspace | ||
| 187 | /// takes it back. | ||
| 188 | formatted: Option<(ExGuid, TextPosition, usize)>, | ||
| 182 | } | 189 | } |
| 183 | 190 | ||
| 184 | /// Imported page state the editable content does not carry. | 191 | /// Imported page state the editable content does not carry. |
| ... | @@ -1211,6 +1218,7 @@ impl CanvasEditor { | ... | @@ -1211,6 +1218,7 @@ impl CanvasEditor { |
| 1211 | fresh.stored = self.stored.take(); | 1218 | fresh.stored = self.stored.take(); |
| 1212 | fresh.default_font = std::mem::take(&mut self.default_font); | 1219 | fresh.default_font = std::mem::take(&mut self.default_font); |
| 1213 | fresh.styles = std::mem::take(&mut self.styles); | 1220 | fresh.styles = std::mem::take(&mut self.styles); |
| 1221 | fresh.markdown = self.markdown.take(); | ||
| 1214 | *self = fresh; | 1222 | *self = fresh; |
| 1215 | Ok(true) | 1223 | Ok(true) |
| 1216 | } | 1224 | } |
| ... | @@ -1583,6 +1591,8 @@ impl CanvasEditor { | ... | @@ -1583,6 +1591,8 @@ impl CanvasEditor { |
| 1583 | provisional: BTreeMap::new(), | 1591 | provisional: BTreeMap::new(), |
| 1584 | default_font: DefaultFont::default(), | 1592 | default_font: DefaultFont::default(), |
| 1585 | styles: BTreeMap::new(), | 1593 | styles: BTreeMap::new(), |
| 1594 | markdown: None, | ||
| 1595 | formatted: None, | ||
| 1586 | }) | 1596 | }) |
| 1587 | } | 1597 | } |
| 1588 | 1598 | ||
| ... | @@ -3525,13 +3535,13 @@ impl CanvasEditor { | ... | @@ -3525,13 +3535,13 @@ impl CanvasEditor { |
| 3525 | } | 3535 | } |
| 3526 | 3536 | ||
| 3527 | /// Types `text` at the selection; a space ends a typed URL, which becomes a link. A line | 3537 | /// Types `text` at the selection; a space ends a typed URL, which becomes a link. A line |
| 3528 | /// of text joins the typing run before it. | 3538 | /// of text joins the typing run before it, and may complete a Markdown shortcut. |
| 3529 | pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { | 3539 | pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { |
| 3530 | if text.is_empty() || text.contains('\n') { | 3540 | if text.is_empty() || text.contains('\n') { |
| 3531 | self.insert_text(engine, text) | 3541 | return self.insert_text(engine, text); |
| 3532 | } else { | ||
| 3533 | self.typed(|editor| editor.insert_text(engine, text)) | ||
| 3534 | } | 3542 | } |
| 3543 | self.typed(|editor| editor.insert_text(engine, text))?; | ||
| 3544 | self.markdown(engine, text) | ||
| 3535 | } | 3545 | } |
| 3536 | 3546 | ||
| 3537 | /// Insert Symbol: `symbol` at the selection, as an undo step that neither joins the | 3547 | /// Insert Symbol: `symbol` at the selection, as an undo step that neither joins the |
| ... | @@ -3867,6 +3877,12 @@ impl CanvasEditor { | ... | @@ -3867,6 +3877,12 @@ impl CanvasEditor { |
| 3867 | let [anchor, focus] = selection.positions; | 3877 | let [anchor, focus] = selection.positions; |
| 3868 | let mut range = anchor.min(focus)..anchor.max(focus); | 3878 | let mut range = anchor.min(focus)..anchor.max(focus); |
| 3869 | let at_caret = range.is_empty(); | 3879 | let at_caret = range.is_empty(); |
| 3880 | if backward | ||
| 3881 | && at_caret | ||
| 3882 | && self.formatted.take() == Some((self.active_outline().id, focus, self.undo.len())) | ||
| 3883 | { | ||
| 3884 | return self.undo(engine); | ||
| 3885 | } | ||
| 3870 | if at_caret { | 3886 | if at_caret { |
| 3871 | let outline = self.active_outline(); | 3887 | let outline = self.active_outline(); |
| 3872 | let paragraph = outline.paragraph_layout(focus.paragraph)?; | 3888 | let paragraph = outline.paragraph_layout(focus.paragraph)?; |
| ... | @@ -4036,6 +4052,7 @@ impl CanvasEditor { | ... | @@ -4036,6 +4052,7 @@ impl CanvasEditor { |
| 4036 | if let Some((page, _)) = &self.stored { | 4052 | if let Some((page, _)) = &self.stored { |
| 4037 | parked.refresh(page.clone(), engine)?; | 4053 | parked.refresh(page.clone(), engine)?; |
| 4038 | parked.default_font = std::mem::take(&mut self.default_font); | 4054 | parked.default_font = std::mem::take(&mut self.default_font); |
| 4055 | parked.markdown = self.markdown.take(); | ||
| 4039 | *self = parked; | 4056 | *self = parked; |
| 4040 | } | 4057 | } |
| 4041 | Ok(()) | 4058 | Ok(()) |
crates/canvas/src/editor/format.rs+30-24| ... | @@ -542,6 +542,32 @@ fn common<T: PartialEq>(mut values: impl Iterator<Item = Option<T>>) -> Option<T | ... | @@ -542,6 +542,32 @@ fn common<T: PartialEq>(mut values: impl Iterator<Item = Option<T>>) -> Option<T |
| 542 | 542 | ||
| 543 | /// `format` moved from paragraph style `old` to `new`: what the old style gave, or nothing | 543 | /// `format` moved from paragraph style `old` to `new`: what the old style gave, or nothing |
| 544 | /// did, the new one gives. | 544 | /// did, the new one gives. |
| 545 | /// `text` in paragraph style `definition` from style format `old`: it loses its character | ||
| 546 | /// formatting but links, fields and language. | ||
| 547 | pub(super) fn styled(text: &Paragraph, old: &Format, definition: &Definition) -> Paragraph { | ||
| 548 | restyle(text, 0..text.text().len(), |format| { | ||
| 549 | // An equation keeps its own formatting; what it took from the old style it takes | ||
| 550 | // from the new. | ||
| 551 | if [format.math, format.embedded_object].contains(&Some(true)) { | ||
| 552 | *format = followed(format, old, &definition.format); | ||
| 553 | return; | ||
| 554 | } | ||
| 555 | *format = Format { | ||
| 556 | hidden: format.hidden, | ||
| 557 | hyperlink: format.hyperlink, | ||
| 558 | hyperlink_label: format.hyperlink_label, | ||
| 559 | math: format.math, | ||
| 560 | embedded_object: format.embedded_object, | ||
| 561 | language: format.language, | ||
| 562 | alignment: format.alignment, | ||
| 563 | rtl: format.rtl, | ||
| 564 | list_spacing: format.list_spacing, | ||
| 565 | math_object: format.math_object.clone(), | ||
| 566 | ..definition.format.clone() | ||
| 567 | }; | ||
| 568 | }) | ||
| 569 | } | ||
| 570 | |||
| 545 | fn followed(format: &Format, old: &Format, new: &Format) -> Format { | 571 | fn followed(format: &Format, old: &Format, new: &Format) -> Format { |
| 546 | let mut format = format.clone(); | 572 | let mut format = format.clone(); |
| 547 | macro_rules! follow { | 573 | macro_rules! follow { |
| ... | @@ -570,7 +596,7 @@ fn followed(format: &Format, old: &Format, new: &Format) -> Format { | ... | @@ -570,7 +596,7 @@ fn followed(format: &Format, old: &Format, new: &Format) -> Format { |
| 570 | } | 596 | } |
| 571 | 597 | ||
| 572 | /// Seconds since 1980, as note tags date themselves. | 598 | /// Seconds since 1980, as note tags date themselves. |
| 573 | fn time32() -> Option<u32> { | 599 | pub(super) fn time32() -> Option<u32> { |
| 574 | let now = web_time::SystemTime::now() | 600 | let now = web_time::SystemTime::now() |
| 575 | .duration_since(web_time::UNIX_EPOCH) | 601 | .duration_since(web_time::UNIX_EPOCH) |
| 576 | .ok()? | 602 | .ok()? |
| ... | @@ -917,27 +943,7 @@ impl CanvasEditor { | ... | @@ -917,27 +943,7 @@ impl CanvasEditor { |
| 917 | node.style = Some(style); | 943 | node.style = Some(style); |
| 918 | let old = &olds[&node.id]; | 944 | let old = &olds[&node.id]; |
| 919 | let text = &mut node.text_mut().unwrap().text; | 945 | let text = &mut node.text_mut().unwrap().text; |
| 920 | *text = restyle(text, 0..text.text().len(), |format| { | 946 | *text = styled(text, old, definition); |
| 921 | // An equation keeps its own formatting; what it took from the | ||
| 922 | // old style it takes from the new. | ||
| 923 | if [format.math, format.embedded_object].contains(&Some(true)) { | ||
| 924 | *format = followed(format, old, &definition.format); | ||
| 925 | return; | ||
| 926 | } | ||
| 927 | *format = Format { | ||
| 928 | hidden: format.hidden, | ||
| 929 | hyperlink: format.hyperlink, | ||
| 930 | hyperlink_label: format.hyperlink_label, | ||
| 931 | math: format.math, | ||
| 932 | embedded_object: format.embedded_object, | ||
| 933 | language: format.language, | ||
| 934 | alignment: format.alignment, | ||
| 935 | rtl: format.rtl, | ||
| 936 | list_spacing: format.list_spacing, | ||
| 937 | math_object: format.math_object.clone(), | ||
| 938 | ..definition.format.clone() | ||
| 939 | }; | ||
| 940 | }); | ||
| 941 | } | 947 | } |
| 942 | } | 948 | } |
| 943 | } | 949 | } |
| ... | @@ -1286,7 +1292,7 @@ impl CanvasEditor { | ... | @@ -1286,7 +1292,7 @@ impl CanvasEditor { |
| 1286 | /// Takes tags of `kind` from `lists`, or with `added`, the definition's identity, its | 1292 | /// Takes tags of `kind` from `lists`, or with `added`, the definition's identity, its |
| 1287 | /// shape and when, gives the last list one in place of any of its action type; an element | 1293 | /// shape and when, gives the last list one in place of any of its action type; an element |
| 1288 | /// holds one tag of each action type, stored newest first. | 1294 | /// holds one tag of each action type, stored newest first. |
| 1289 | fn retag<const N: usize>( | 1295 | pub(super) fn retag<const N: usize>( |
| 1290 | &self, | 1296 | &self, |
| 1291 | mut lists: [&mut Vec<Tag>; N], | 1297 | mut lists: [&mut Vec<Tag>; N], |
| 1292 | kind: &Kind<'static>, | 1298 | kind: &Kind<'static>, |
| ... | @@ -1328,7 +1334,7 @@ impl CanvasEditor { | ... | @@ -1328,7 +1334,7 @@ impl CanvasEditor { |
| 1328 | 1334 | ||
| 1329 | /// Checks the check boxes among `tags`, or clears them once all are checked; OneNote keeps | 1335 | /// Checks the check boxes among `tags`, or clears them once all are checked; OneNote keeps |
| 1330 | /// a cleared box's completion time as zero. | 1336 | /// a cleared box's completion time as zero. |
| 1331 | fn toggle_checks(&self, mut tags: Vec<&mut Tag>) { | 1337 | pub(super) fn toggle_checks(&self, mut tags: Vec<&mut Tag>) { |
| 1332 | let checked = tags | 1338 | let checked = tags |
| 1333 | .iter() | 1339 | .iter() |
| 1334 | .filter(|tag| self.check_box(tag).is_some()) | 1340 | .filter(|tag| self.check_box(tag).is_some()) |
crates/canvas/src/editor/markdown.rs created+668| ... | @@ -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 | } | ||
crates/canvas/src/interaction/mod.rs+1| ... | @@ -363,6 +363,7 @@ impl PageView { | ... | @@ -363,6 +363,7 @@ impl PageView { |
| 363 | scene: Option<(PageScene, [f32; 2])>, | 363 | scene: Option<(PageScene, [f32; 2])>, |
| 364 | ) -> CanvasEditor { | 364 | ) -> CanvasEditor { |
| 365 | editor.default_font = std::mem::take(&mut self.editor.default_font); | 365 | editor.default_font = std::mem::take(&mut self.editor.default_font); |
| 366 | editor.markdown = self.editor.markdown.take(); | ||
| 366 | let left = std::mem::replace(&mut self.editor, editor); | 367 | let left = std::mem::replace(&mut self.editor, editor); |
| 367 | self.scene = scene; | 368 | self.scene = scene; |
| 368 | self.drag = None; | 369 | self.drag = None; |
crates/mobile/include/snowbound.h+1| ... | @@ -84,6 +84,7 @@ uint8_t sb_view_target(const View *view, float x, float y); | ... | @@ -84,6 +84,7 @@ uint8_t sb_view_target(const View *view, float x, float y); |
| 84 | void sb_view_pressure(View *view, float pressure); | 84 | void sb_view_pressure(View *view, float pressure); |
| 85 | void sb_set_snap_to_grid(bool on); | 85 | void sb_set_snap_to_grid(bool on); |
| 86 | void sb_set_default_font(const char *face, float size); | 86 | void sb_set_default_font(const char *face, float size); |
| 87 | void sb_set_markdown_shortcuts(bool on); | ||
| 87 | bool sb_view_press(View *view, float x, float y); | 88 | bool sb_view_press(View *view, float x, float y); |
| 88 | bool sb_view_drag(View *view, float x, float y); | 89 | bool sb_view_drag(View *view, float x, float y); |
| 89 | bool sb_view_release(View *view); | 90 | bool sb_view_release(View *view); |
crates/mobile/src/lib.rs+15-6| ... | @@ -154,7 +154,10 @@ impl Canvas { | ... | @@ -154,7 +154,10 @@ impl Canvas { |
| 154 | ); | 154 | ); |
| 155 | page.touch = true; | 155 | page.touch = true; |
| 156 | page.host_viewport = true; | 156 | page.host_viewport = true; |
| 157 | (page.snap_to_grid, page.editor.default_font) = options().clone(); | 157 | let markdown; |
| 158 | (page.snap_to_grid, page.editor.default_font, markdown) = options().clone(); | ||
| 159 | page.editor.markdown = | ||
| 160 | markdown.then(|| notebook::sidecar::themes::built_in().swap_remove(0).sheet()); | ||
| 158 | Ok(Self { | 161 | Ok(Self { |
| 159 | page, | 162 | page, |
| 160 | space, | 163 | space, |
| ... | @@ -636,12 +639,12 @@ fn pool_engine(engine: TextEngine) { | ... | @@ -636,12 +639,12 @@ fn pool_engine(engine: TextEngine) { |
| 636 | } | 639 | } |
| 637 | } | 640 | } |
| 638 | 641 | ||
| 639 | /// The reader's Snap To Grid and Default font, which every page opened from then on takes, | 642 | /// The reader's Snap To Grid, Default font and Markdown Shortcuts, which every page opened |
| 640 | /// and new pages' titles the font's face. | 643 | /// from then on takes, and new pages' titles the font's face. |
| 641 | static OPTIONS: LazyLock<Mutex<(bool, canvas::editor::DefaultFont)>> = | 644 | static OPTIONS: LazyLock<Mutex<(bool, canvas::editor::DefaultFont, bool)>> = |
| 642 | LazyLock::new(|| Mutex::new((true, canvas::editor::DefaultFont::default()))); | 645 | LazyLock::new(|| Mutex::new((true, canvas::editor::DefaultFont::default(), true))); |
| 643 | 646 | ||
| 644 | fn options() -> MutexGuard<'static, (bool, canvas::editor::DefaultFont)> { | 647 | fn options() -> MutexGuard<'static, (bool, canvas::editor::DefaultFont, bool)> { |
| 645 | OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) | 648 | OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) |
| 646 | } | 649 | } |
| 647 | 650 | ||
| ... | @@ -664,6 +667,12 @@ pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) { | ... | @@ -664,6 +667,12 @@ pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) { |
| 664 | }; | 667 | }; |
| 665 | } | 668 | } |
| 666 | 669 | ||
| 670 | /// Markdown Shortcuts: typed Markdown becomes formatting. | ||
| 671 | #[unsafe(no_mangle)] | ||
| 672 | pub extern "C" fn sb_set_markdown_shortcuts(on: bool) { | ||
| 673 | options().2 = on; | ||
| 674 | } | ||
| 675 | |||
| 667 | pub struct View { | 676 | pub struct View { |
| 668 | /// Taken apart on drop, which returns its text engine to `ENGINES`. | 677 | /// Taken apart on drop, which returns its text engine to `ENGINES`. |
| 669 | canvas: ManuallyDrop<Canvas>, | 678 | canvas: ManuallyDrop<Canvas>, |
crates/mobile/src/tests.rs+8-3| ... | @@ -742,15 +742,20 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() { | ... | @@ -742,15 +742,20 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() { |
| 742 | } | 742 | } |
| 743 | 743 | ||
| 744 | #[test] | 744 | #[test] |
| 745 | fn pages_open_with_the_snap_and_default_font_options_set() { | 745 | fn pages_open_with_the_snap_default_font_and_markdown_options_set() { |
| 746 | // OneNote's defaults, so pages other tests open meanwhile see no change. | 746 | // The defaults, so pages other tests open meanwhile see no change. |
| 747 | sb_set_snap_to_grid(true); | 747 | sb_set_snap_to_grid(true); |
| 748 | unsafe { sb_set_default_font(c"Calibri".as_ptr(), 11.0) }; | 748 | unsafe { sb_set_default_font(c"Calibri".as_ptr(), 11.0) }; |
| 749 | assert_eq!(*options(), (true, canvas::editor::DefaultFont::default())); | 749 | sb_set_markdown_shortcuts(true); |
| 750 | assert_eq!( | ||
| 751 | *options(), | ||
| 752 | (true, canvas::editor::DefaultFont::default(), true) | ||
| 753 | ); | ||
| 750 | let (_directory, section) = features(); | 754 | let (_directory, section) = features(); |
| 751 | let canvas = canvas(&section, "Paragraph controls"); | 755 | let canvas = canvas(&section, "Paragraph controls"); |
| 752 | assert!(canvas.page.snap_to_grid); | 756 | assert!(canvas.page.snap_to_grid); |
| 753 | assert_eq!(canvas.page.editor.default_font.face, "Calibri"); | 757 | assert_eq!(canvas.page.editor.default_font.face, "Calibri"); |
| 758 | assert!(canvas.page.editor.markdown.is_some()); | ||
| 754 | } | 759 | } |
| 755 | 760 | ||
| 756 | #[test] | 761 | #[test] |
crates/snowbound/src/commands.rs+17| ... | @@ -69,6 +69,8 @@ pub enum Id { | ... | @@ -69,6 +69,8 @@ pub enum Id { |
| 69 | FullPageView, | 69 | FullPageView, |
| 70 | /// OneNote's Hide Spelling Errors, which leaves misspelled words unmarked. | 70 | /// OneNote's Hide Spelling Errors, which leaves misspelled words unmarked. |
| 71 | HideSpelling, | 71 | HideSpelling, |
| 72 | /// Typed Markdown becomes formatting. | ||
| 73 | MarkdownShortcuts, | ||
| 72 | /// The Spelling pane, on the next marked word. | 74 | /// The Spelling pane, on the next marked word. |
| 73 | Spelling, | 75 | Spelling, |
| 74 | PageColor, | 76 | PageColor, |
| ... | @@ -128,6 +130,11 @@ pub fn offered(id: Id) -> bool { | ... | @@ -128,6 +130,11 @@ pub fn offered(id: Id) -> bool { |
| 128 | id != Id::ScreenClipping || cfg!(target_os = "macos") | 130 | id != Id::ScreenClipping || cfg!(target_os = "macos") |
| 129 | } | 131 | } |
| 130 | 132 | ||
| 133 | /// What the editor's Markdown shortcuts take: OneNote 2010's gallery styles when `on`. | ||
| 134 | pub fn markdown(on: bool) -> Option<BTreeMap<String, onestore::page::Definition>> { | ||
| 135 | on.then(|| notebook::sidecar::themes::built_in().swap_remove(0).sheet()) | ||
| 136 | } | ||
| 137 | |||
| 131 | /// What a menu or toolbar offers: a command, or one of a list's entries. | 138 | /// What a menu or toolbar offers: a command, or one of a list's entries. |
| 132 | #[derive(Clone, Debug, PartialEq)] | 139 | #[derive(Clone, Debug, PartialEq)] |
| 133 | pub enum Choice { | 140 | pub enum Choice { |
| ... | @@ -372,6 +379,7 @@ pub const COMMANDS: &[Command] = &[ | ... | @@ -372,6 +379,7 @@ pub const COMMANDS: &[Command] = &[ |
| 372 | row!(Id::PageList, "Page List", NONE, NONE), | 379 | row!(Id::PageList, "Page List", NONE, NONE), |
| 373 | row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE), | 380 | row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE), |
| 374 | row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE), | 381 | row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE), |
| 382 | row!(Id::MarkdownShortcuts, "Markdown Shortcuts", NONE, NONE), | ||
| 375 | row!( | 383 | row!( |
| 376 | Id::Spelling, | 384 | Id::Spelling, |
| 377 | "Spelling…", | 385 | "Spelling…", |
| ... | @@ -1259,6 +1267,10 @@ impl State { | ... | @@ -1259,6 +1267,10 @@ impl State { |
| 1259 | enabled: !modal && self.spelling.is_some(), | 1267 | enabled: !modal && self.spelling.is_some(), |
| 1260 | checked: Some(self.hide_spelling), | 1268 | checked: Some(self.hide_spelling), |
| 1261 | }, | 1269 | }, |
| 1270 | Id::MarkdownShortcuts => Status { | ||
| 1271 | enabled: !modal, | ||
| 1272 | checked: Some(self.view.editor.markdown.is_some()), | ||
| 1273 | }, | ||
| 1262 | Id::Spelling => enabled(text && self.view.spelling.is_some()), | 1274 | Id::Spelling => enabled(text && self.view.spelling.is_some()), |
| 1263 | Id::FormatPainter => Status { | 1275 | Id::FormatPainter => Status { |
| 1264 | enabled: text, | 1276 | enabled: text, |
| ... | @@ -1628,6 +1640,11 @@ impl State { | ... | @@ -1628,6 +1640,11 @@ impl State { |
| 1628 | self.save_settings(); | 1640 | self.save_settings(); |
| 1629 | return Ok(()); | 1641 | return Ok(()); |
| 1630 | } | 1642 | } |
| 1643 | Id::MarkdownShortcuts => { | ||
| 1644 | self.view.editor.markdown = markdown(self.view.editor.markdown.is_none()); | ||
| 1645 | self.save_settings(); | ||
| 1646 | return Ok(()); | ||
| 1647 | } | ||
| 1631 | Id::PagesMatchTheme => { | 1648 | Id::PagesMatchTheme => { |
| 1632 | self.light_pages = !self.light_pages; | 1649 | self.light_pages = !self.light_pages; |
| 1633 | self.follow_color_scheme(); | 1650 | self.follow_color_scheme(); |
crates/snowbound/src/main.rs+2| ... | @@ -1294,6 +1294,7 @@ impl State { | ... | @@ -1294,6 +1294,7 @@ impl State { |
| 1294 | }; | 1294 | }; |
| 1295 | state.view.snap_to_grid = !stored.ignore_grid; | 1295 | state.view.snap_to_grid = !stored.ignore_grid; |
| 1296 | state.view.editor.default_font = stored.default_font; | 1296 | state.view.editor.default_font = stored.default_font; |
| 1297 | state.view.editor.markdown = commands::markdown(!stored.ignore_markdown); | ||
| 1297 | // A notebook opened from its server that couldn't sign in asks to, as the Finder does. | 1298 | // A notebook opened from its server that couldn't sign in asks to, as the Finder does. |
| 1298 | let unsigned = state.notebooks.iter().find(|library| { | 1299 | let unsigned = state.notebooks.iter().find(|library| { |
| 1299 | library.notebook.is_err() && library::server_address(&library.location).is_some() | 1300 | library.notebook.is_err() && library::server_address(&library.location).is_some() |
| ... | @@ -2859,6 +2860,7 @@ impl State { | ... | @@ -2859,6 +2860,7 @@ impl State { |
| 2859 | Rule, | 2860 | Rule, |
| 2860 | Run(Cmd::HideSpelling), | 2861 | Run(Cmd::HideSpelling), |
| 2861 | Run(Cmd::Spelling), | 2862 | Run(Cmd::Spelling), |
| 2863 | Run(Cmd::MarkdownShortcuts), | ||
| 2862 | ]; | 2864 | ]; |
| 2863 | ui.open( | 2865 | ui.open( |
| 2864 | "view", | 2866 | "view", |
crates/snowbound/src/options.rs+12| ... | @@ -117,6 +117,15 @@ const SECTIONS: &[Section] = &[ | ... | @@ -117,6 +117,15 @@ const SECTIONS: &[Section] = &[ |
| 117 | control: Control::Check(|options| &mut options.hide_spelling), | 117 | control: Control::Check(|options| &mut options.hide_spelling), |
| 118 | }], | 118 | }], |
| 119 | }, | 119 | }, |
| 120 | Group { | ||
| 121 | heading: "AutoFormat", | ||
| 122 | rows: &[Row { | ||
| 123 | label: "Markdown shortcuts", | ||
| 124 | keywords: "markdown autoformat heading bullet numbering list to do quote \ | ||
| 125 | bold italic strikethrough code typing", | ||
| 126 | control: Control::Check(|options| &mut options.markdown), | ||
| 127 | }], | ||
| 128 | }, | ||
| 120 | Group { | 129 | Group { |
| 121 | heading: "Pen", | 130 | heading: "Pen", |
| 122 | rows: &[Row { | 131 | rows: &[Row { |
| ... | @@ -227,6 +236,7 @@ pub struct Options { | ... | @@ -227,6 +236,7 @@ pub struct Options { |
| 227 | color_scheme: ColorScheme, | 236 | color_scheme: ColorScheme, |
| 228 | pages_match: bool, | 237 | pages_match: bool, |
| 229 | hide_spelling: bool, | 238 | hide_spelling: bool, |
| 239 | markdown: bool, | ||
| 230 | automatic_updates: bool, | 240 | automatic_updates: bool, |
| 231 | pen_pressure: bool, | 241 | pen_pressure: bool, |
| 232 | default_font: DefaultFont, | 242 | default_font: DefaultFont, |
| ... | @@ -323,6 +333,7 @@ impl State { | ... | @@ -323,6 +333,7 @@ impl State { |
| 323 | color_scheme: self.color_scheme, | 333 | color_scheme: self.color_scheme, |
| 324 | pages_match: !self.light_pages, | 334 | pages_match: !self.light_pages, |
| 325 | hide_spelling: self.hide_spelling, | 335 | hide_spelling: self.hide_spelling, |
| 336 | markdown: self.view.editor.markdown.is_some(), | ||
| 326 | automatic_updates: self.updates.automatic(), | 337 | automatic_updates: self.updates.automatic(), |
| 327 | pen_pressure: self.pen_pressure, | 338 | pen_pressure: self.pen_pressure, |
| 328 | default_font: self.view.editor.default_font.clone(), | 339 | default_font: self.view.editor.default_font.clone(), |
| ... | @@ -629,6 +640,7 @@ impl State { | ... | @@ -629,6 +640,7 @@ impl State { |
| 629 | self.color_scheme = options.color_scheme; | 640 | self.color_scheme = options.color_scheme; |
| 630 | self.light_pages = !options.pages_match; | 641 | self.light_pages = !options.pages_match; |
| 631 | self.hide_spelling = options.hide_spelling; | 642 | self.hide_spelling = options.hide_spelling; |
| 643 | self.view.editor.markdown = crate::commands::markdown(options.markdown); | ||
| 632 | self.pen_pressure = options.pen_pressure; | 644 | self.pen_pressure = options.pen_pressure; |
| 633 | self.view.editor.default_font = options.default_font; | 645 | self.view.editor.default_font = options.default_font; |
| 634 | self.page_tabs_left = options.page_tabs_left; | 646 | self.page_tabs_left = options.page_tabs_left; |
crates/snowbound/src/settings.rs+4| ... | @@ -24,6 +24,8 @@ pub struct Settings { | ... | @@ -24,6 +24,8 @@ pub struct Settings { |
| 24 | pub light_pages: bool, | 24 | pub light_pages: bool, |
| 25 | /// Leaves misspelled words unmarked, as OneNote's Hide Spelling Errors. | 25 | /// Leaves misspelled words unmarked, as OneNote's Hide Spelling Errors. |
| 26 | pub hide_spelling: bool, | 26 | pub hide_spelling: bool, |
| 27 | /// Types Markdown as it is: Markdown Shortcuts off. | ||
| 28 | pub ignore_markdown: bool, | ||
| 27 | /// Where searches look first, as "Set This Scope as Default" chose. | 29 | /// Where searches look first, as "Set This Scope as Default" chose. |
| 28 | pub search_scope: crate::search::Scope, | 30 | pub search_scope: crate::search::Scope, |
| 29 | /// The tag list Customize Tags edits; none keeps OneNote's. | 31 | /// The tag list Customize Tags edits; none keeps OneNote's. |
| ... | @@ -179,6 +181,7 @@ impl crate::State { | ... | @@ -179,6 +181,7 @@ impl crate::State { |
| 179 | color_scheme: self.color_scheme, | 181 | color_scheme: self.color_scheme, |
| 180 | light_pages: self.light_pages, | 182 | light_pages: self.light_pages, |
| 181 | hide_spelling: self.hide_spelling, | 183 | hide_spelling: self.hide_spelling, |
| 184 | ignore_markdown: self.view.editor.markdown.is_none(), | ||
| 182 | search_scope: self.search.default, | 185 | search_scope: self.search.default, |
| 183 | tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()), | 186 | tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()), |
| 184 | manual_updates: !self.updates.automatic(), | 187 | manual_updates: !self.updates.automatic(), |
| ... | @@ -242,6 +245,7 @@ mod tests { | ... | @@ -242,6 +245,7 @@ mod tests { |
| 242 | color_scheme: ColorScheme::Dark, | 245 | color_scheme: ColorScheme::Dark, |
| 243 | light_pages: true, | 246 | light_pages: true, |
| 244 | hide_spelling: true, | 247 | hide_spelling: true, |
| 248 | ignore_markdown: true, | ||
| 245 | search_scope: crate::search::Scope::Notebook, | 249 | search_scope: crate::search::Scope::Notebook, |
| 246 | tags: Some(vec![canvas::editor::NoteTag { | 250 | tags: Some(vec![canvas::editor::NoteTag { |
| 247 | label: "Snow check".into(), | 251 | label: "Snow check".into(), |