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
6use super::*;
7use onestore::page::{Attachment, MediaIndex};
8use web_time::Instant;
9
10/// A recording in progress.
11pub(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.
21fn 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
46impl 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`).
259const 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%.
263const BUTTON: [f32; 2] = [10.5, 24.0];
264
265impl 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}