| 1 | //! Record Audio as OneNote 2010 records (`corpus/recording`): starting splits the caret's |
| 2 | //! paragraph around a line saying when, in the page's `cite` style and linked to the start; |
| 3 | //! text written while recording links to the moment it was written; the finished file takes |
| 4 | //! its place after the caret's paragraph. |
| 5 | |
| 6 | use super::*; |
| 7 | use onestore::page::{Attachment, MediaIndex}; |
| 8 | use web_time::Instant; |
| 9 | |
| 10 | /// A recording in progress. |
| 11 | pub(super) struct Live { |
| 12 | id: [u8; 16], |
| 13 | /// When the recording would have started had it never paused. |
| 14 | started: Instant, |
| 15 | paused: Option<Instant>, |
| 16 | /// The blank paragraph before the line saying when, which the file goes before. |
| 17 | place: ExGuid, |
| 18 | } |
| 19 | |
| 20 | /// OneNote 2010's `cite` quick style: Calibri 9 in dark grey. |
| 21 | fn cite() -> Definition { |
| 22 | Definition { |
| 23 | kind: Kind::Style { |
| 24 | name: Some("cite".into()), |
| 25 | next: None, |
| 26 | }, |
| 27 | format: Format { |
| 28 | bold: Some(false), |
| 29 | italic: Some(false), |
| 30 | underline: Some(false), |
| 31 | strike: Some(false), |
| 32 | superscript: Some(false), |
| 33 | subscript: Some(false), |
| 34 | font: Some("Calibri".into()), |
| 35 | font_size: Some(9.0), |
| 36 | color: Some(0x595959), |
| 37 | highlight: Some(0xff00_0000), |
| 38 | space_before: Some(0.0), |
| 39 | space_after: Some(0.0), |
| 40 | line_spacing: Some(0.0), |
| 41 | ..Format::default() |
| 42 | }, |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | impl CanvasEditor { |
| 47 | /// The recording that text written now links to. |
| 48 | pub fn recording(&self) -> Option<[u8; 16]> { |
| 49 | self.recording.as_ref().map(|live| live.id) |
| 50 | } |
| 51 | |
| 52 | /// How far the recording has got, its pauses left out. |
| 53 | pub fn recording_ms(&self) -> Option<u32> { |
| 54 | let live = self.recording.as_ref()?; |
| 55 | let now = live.paused.unwrap_or_else(Instant::now); |
| 56 | Some(u32::try_from((now - live.started).as_millis()).unwrap_or(u32::MAX)) |
| 57 | } |
| 58 | |
| 59 | /// Pauses or resumes the recording. OneNote links nothing written while it is paused. |
| 60 | pub fn pause_recording(&mut self, paused: bool) { |
| 61 | let Some(live) = &mut self.recording else { |
| 62 | return; |
| 63 | }; |
| 64 | match (live.paused, paused) { |
| 65 | (None, true) => live.paused = Some(Instant::now()), |
| 66 | (Some(since), false) => { |
| 67 | live.started += since.elapsed(); |
| 68 | live.paused = None; |
| 69 | } |
| 70 | _ => {} |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | pub fn recording_paused(&self) -> bool { |
| 75 | self.recording |
| 76 | .as_ref() |
| 77 | .is_some_and(|live| live.paused.is_some()) |
| 78 | } |
| 79 | |
| 80 | /// Starts a recording at the caret, returning its identity: the caret's paragraph splits |
| 81 | /// there around a blank line, `label` (saying when, in the `cite` style, linked to the |
| 82 | /// start) and the blank paragraph the caret starts. An empty first half is the blank |
| 83 | /// line; a caret in the title records at the body's start. |
| 84 | pub fn start_recording( |
| 85 | &mut self, |
| 86 | engine: &mut TextEngine, |
| 87 | label: &str, |
| 88 | ) -> Result<[u8; 16], EditorError> { |
| 89 | if self.recording.is_some() { |
| 90 | return Err(EditError::InvalidRange.into()); |
| 91 | } |
| 92 | self.leave_title(engine)?; |
| 93 | if let Some(id) = self.across(engine, |editor, engine| { |
| 94 | editor.start_recording(engine, label) |
| 95 | })? { |
| 96 | return Ok(id); |
| 97 | } |
| 98 | let id = onestore::page::text::new_guid()?; |
| 99 | let [anchor, focus] = self.selection().positions; |
| 100 | let (start, end) = (anchor.min(focus), anchor.max(focus)); |
| 101 | let mut format = self.typing_format(start)?; |
| 102 | link::unlinked(&mut format); |
| 103 | let language = format.language; |
| 104 | let head_empty = start.offset == 0; |
| 105 | let parts = if head_empty { 3 } else { 4 }; |
| 106 | let mut edit = self.active_outline().document.replace( |
| 107 | start..end, |
| 108 | vec![Paragraph::new(String::new(), format); parts], |
| 109 | )?; |
| 110 | let style = match self.definitions.iter().find(|(_, definition)| { |
| 111 | matches!(&definition.kind, Kind::Style { name: Some(name), .. } if name == "cite") |
| 112 | }) { |
| 113 | Some((style, _)) => *style, |
| 114 | None => { |
| 115 | let style = onestore::page::text::new_id()?; |
| 116 | self.definitions.insert(style, cite()); |
| 117 | style |
| 118 | } |
| 119 | }; |
| 120 | if head_empty { |
| 121 | // The caret's text, now the last part, keeps its tags and link, as Attach File's does. |
| 122 | let [head, .., tail] = &mut edit.replacement[..parts] else { |
| 123 | unreachable!("a split holds both halves") |
| 124 | }; |
| 125 | tail.tags = std::mem::take(&mut head.tags); |
| 126 | tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags); |
| 127 | tail.media = std::mem::take(&mut head.media); |
| 128 | } |
| 129 | let place = edit.replacement[parts - 3].id; |
| 130 | let line = &mut edit.replacement[parts - 2]; |
| 131 | line.style = Some(style); |
| 132 | line.lists.clear(); |
| 133 | line.media = MediaIndex { |
| 134 | recordings: vec![id], |
| 135 | time_ms: Some(0), |
| 136 | }; |
| 137 | // The line's text reads back in its style's look. |
| 138 | let look = Format { |
| 139 | language, |
| 140 | ..self.definitions[&style].format.clone() |
| 141 | }; |
| 142 | line.text_mut().unwrap().text = Paragraph::new(label.to_owned(), look); |
| 143 | let caret = TextPosition { |
| 144 | paragraph: start.paragraph + parts - 1, |
| 145 | offset: 0, |
| 146 | }; |
| 147 | self.commit( |
| 148 | engine, |
| 149 | edit, |
| 150 | Selection { |
| 151 | positions: [caret; 2], |
| 152 | affinities: [Affinity::Upstream; 2], |
| 153 | }, |
| 154 | )?; |
| 155 | self.recording = Some(Live { |
| 156 | id, |
| 157 | started: Instant::now(), |
| 158 | paused: None, |
| 159 | place, |
| 160 | }); |
| 161 | Ok(id) |
| 162 | } |
| 163 | |
| 164 | /// Forgets the recording under way, whose file never came; its line stays. |
| 165 | pub fn cancel_recording(&mut self) { |
| 166 | self.recording = None; |
| 167 | } |
| 168 | |
| 169 | /// Stops recording: `file`, the recording made, goes where it started, linked to its |
| 170 | /// start, or at the caret when that place is gone. |
| 171 | pub fn finish_recording( |
| 172 | &mut self, |
| 173 | engine: &mut TextEngine, |
| 174 | mut file: Attachment, |
| 175 | ) -> Result<(), EditorError> { |
| 176 | let Some(live) = self.recording.take() else { |
| 177 | return Err(EditError::InvalidRange.into()); |
| 178 | }; |
| 179 | if file |
| 180 | .recording |
| 181 | .is_none_or(|recording| recording.id != live.id) |
| 182 | { |
| 183 | return Err(EditError::InvalidStructure.into()); |
| 184 | } |
| 185 | let place = self.outlines.iter().find_map(|outline| { |
| 186 | descendants(outline.document.nodes(), None).find_map(|(container, index, node)| { |
| 187 | (node.id == live.place).then(|| (outline.id, container, index, node.clone())) |
| 188 | }) |
| 189 | }); |
| 190 | let Some((outline, container, index, place)) = place else { |
| 191 | return self.insert_attachment(engine, file); |
| 192 | }; |
| 193 | file.layout = Default::default(); |
| 194 | let node = PageParagraph { |
| 195 | id: onestore::page::text::new_id()?, |
| 196 | content: ParagraphContent::Attachment(file), |
| 197 | style: None, |
| 198 | lists: Vec::new(), |
| 199 | tags: Vec::new(), |
| 200 | collapsed: false, |
| 201 | media: MediaIndex { |
| 202 | recordings: vec![live.id], |
| 203 | time_ms: Some(0), |
| 204 | }, |
| 205 | ..place |
| 206 | }; |
| 207 | self.focus_outline(outline)?; |
| 208 | let selection = self.selection(); |
| 209 | self.commit( |
| 210 | engine, |
| 211 | DocumentEdit { |
| 212 | columns: BTreeMap::new(), |
| 213 | container, |
| 214 | range: index..index, |
| 215 | replacement: vec![node], |
| 216 | }, |
| 217 | selection, |
| 218 | ) |
| 219 | } |
| 220 | |
| 221 | /// Links text `edit` writes while recording to the moment it is written, as OneNote |
| 222 | /// links notes taken then; blank paragraphs and those already linked stay as they are. |
| 223 | pub(super) fn link_notes(&self, edit: &mut DocumentEdit) -> Result<(), EditError> { |
| 224 | let Some(time) = self.recording_ms().filter(|_| !self.recording_paused()) else { |
| 225 | return Ok(()); |
| 226 | }; |
| 227 | let id = self.recording().expect("a recording is live"); |
| 228 | let nodes = &self.active_outline().document.container(edit.container)?[edit.range.clone()]; |
| 229 | let before: BTreeMap<ExGuid, &str> = descendants(nodes, None) |
| 230 | .filter_map(|(_, _, node)| Some((node.id, node.text()?.text.text()))) |
| 231 | .collect(); |
| 232 | let mut pending: Vec<&mut PageParagraph> = edit.replacement.iter_mut().collect(); |
| 233 | while let Some(node) = pending.pop() { |
| 234 | let written = node.text().is_some_and(|text| { |
| 235 | !text.text.text().is_empty() && before.get(&node.id) != Some(&text.text.text()) |
| 236 | }); |
| 237 | if written && node.media == MediaIndex::default() { |
| 238 | node.media = MediaIndex { |
| 239 | recordings: vec![id], |
| 240 | time_ms: Some(time), |
| 241 | }; |
| 242 | } |
| 243 | if let ParagraphContent::Table(table) = &mut node.content { |
| 244 | pending.extend( |
| 245 | table |
| 246 | .rows |
| 247 | .iter_mut() |
| 248 | .flat_map(|row| &mut row.cells) |
| 249 | .flat_map(|cell| &mut cell.paragraphs), |
| 250 | ); |
| 251 | } |
| 252 | } |
| 253 | Ok(()) |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | /// How far before a linked note's moment it plays from: OneNote 2010's default (Options, |
| 258 | /// Audio & Video, `corpus/recording/native/read/onenote-audio-video-options.png`). |
| 259 | const REWIND_MS: u32 = 5_000; |
| 260 | |
| 261 | /// A play button's side, and how far its right edge stands before the text it plays from, |
| 262 | /// in points, as OneNote 2010 draws it at 100%. |
| 263 | const BUTTON: [f32; 2] = [10.5, 24.0]; |
| 264 | |
| 265 | impl CanvasEditor { |
| 266 | /// The recording `id` names, in an outline or on the page. |
| 267 | pub(crate) fn recording_file(&self, id: [u8; 16]) -> Option<&Attachment> { |
| 268 | let named = |file: &&Attachment| file.recording.is_some_and(|r| r.id == id); |
| 269 | self.objects |
| 270 | .iter() |
| 271 | .filter_map(|object| object.file().map(|(file, _)| file)) |
| 272 | .find(named) |
| 273 | .or_else(|| { |
| 274 | self.visible_outlines() |
| 275 | .flat_map(|outline| descendants(outline.document.nodes(), None)) |
| 276 | .filter_map(|(_, _, node)| match &node.content { |
| 277 | ParagraphContent::Attachment(file) => Some(file), |
| 278 | _ => None, |
| 279 | }) |
| 280 | .find(named) |
| 281 | }) |
| 282 | } |
| 283 | |
| 284 | /// The note See Playback highlights `at_ms` into recording `id`: the text last linked at |
| 285 | /// or before then. The line saying when recording started, linked at its start, is none. |
| 286 | pub fn played_note(&self, id: [u8; 16], at_ms: u32) -> Option<ExGuid> { |
| 287 | self.visible_outlines() |
| 288 | .flat_map(|outline| descendants(outline.document.nodes(), None)) |
| 289 | .filter(|(_, _, node)| { |
| 290 | node.text().is_some() && node.media.recordings.first() == Some(&id) |
| 291 | }) |
| 292 | .filter_map(|(_, _, node)| Some((node.media.time_ms?, node.id))) |
| 293 | .filter(|(time, _)| (1..=at_ms).contains(time)) |
| 294 | .max_by_key(|(time, _)| *time) |
| 295 | .map(|(_, id)| id) |
| 296 | } |
| 297 | |
| 298 | /// The play button OneNote shows for document point `point`: beside the linked note or |
| 299 | /// the recording level with it, over its outline or in the margin before it. Gives the |
| 300 | /// button's square, the recording and the moment it plays from. |
| 301 | pub(crate) fn play_button(&self, point: [f32; 2]) -> Option<([f32; 4], [u8; 16], u32)> { |
| 302 | let [x, y] = point; |
| 303 | let outline = self.visible_outlines().find(|outline| { |
| 304 | let bounds = outline.bounds(); |
| 305 | (bounds.x0 as f32 - BUTTON[1] - BUTTON[0]..=bounds.x1 as f32).contains(&x) |
| 306 | && (bounds.y0 as f32..=bounds.y1 as f32).contains(&y) |
| 307 | })?; |
| 308 | let [left, top] = outline.origin(); |
| 309 | let square = |text_left: f32, line_top: f32, height: f32| { |
| 310 | let x1 = left + text_left - BUTTON[1]; |
| 311 | let y0 = top + line_top + (height - BUTTON[0]) / 2.0; |
| 312 | [x1 - BUTTON[0], y0, x1, y0 + BUTTON[0]] |
| 313 | }; |
| 314 | let shaped = outline.shaped(); |
| 315 | let (rect, recording, time) = if let Some(paragraph) = shaped |
| 316 | .paragraphs |
| 317 | .iter() |
| 318 | .find(|p| (top + p.origin[1]..=top + p.origin[1] + p.text.height()).contains(&y)) |
| 319 | { |
| 320 | let (_, _, node) = descendants(outline.document.nodes(), None) |
| 321 | .find(|(_, _, node)| node.id == paragraph.id)?; |
| 322 | let line = paragraph |
| 323 | .text |
| 324 | .lines() |
| 325 | .next() |
| 326 | .map_or(paragraph.text.height(), |(_, line)| line.height); |
| 327 | ( |
| 328 | square(paragraph.origin[0], paragraph.origin[1], line), |
| 329 | *node.media.recordings.first()?, |
| 330 | node.media.time_ms?.saturating_sub(REWIND_MS), |
| 331 | ) |
| 332 | } else { |
| 333 | let object = shaped.objects.iter().find(|object| { |
| 334 | matches!(object.kind, crate::outline::ObjectKind::File(_)) |
| 335 | && (top + object.rect[1]..=top + object.bottom).contains(&y) |
| 336 | })?; |
| 337 | let [x0, y0, _, y1] = object.rect; |
| 338 | let recording = self.attachment(object.id)?.recording?.id; |
| 339 | (square(x0, y0, y1 - y0), recording, 0) |
| 340 | }; |
| 341 | self.recording_file(recording)?; |
| 342 | Some((rect, recording, time)) |
| 343 | } |
| 344 | } |