From ce6f0270afea1cee267fdbdc5e772e598ee773fc Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 15 Oct 2026 23:18:20 -0700 Subject: [PATCH] feat(mobile): flow every text box on a page into the Reading View The Reading View was offered only for a page with a title and one text box. A page with several now reads them top to bottom in the column, left column first where boxes sit side by side, each wrapped to the screen's width and a grid row apart. Every box stays editable where it is: tapping one puts the caret there, and typing, marked text, undo and redo reach the box on the page without moving it. Pages whose text holds right-to-left writing, tables or equations still show only as laid out. Refs #128 Assisted-by: claude-opus-5.5 --- crates/canvas/src/interaction/mod.rs | 30 ++-- crates/canvas/src/interaction/tests.rs | 173 +++++++++++++++++++- crates/canvas/src/reading.rs | 208 ++++++++++++++----------- crates/mobile/src/tests.rs | 102 ++++++++++++ 4 files changed, 411 insertions(+), 102 deletions(-) diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index bb353baff3ea3195187ff6c959cb05b643bbb161..d629ab8ea04d59e63016b3196518c8716cb922b3 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -1100,6 +1100,15 @@ impl PageView { } pub fn text_block(&self, point: [f32; 2]) -> Option<[f32; 4]> { + self.nearest_text(point, |_| true).map(|(_, rect)| rect) + } + + /// The outline `keep` accepts nearest `point`, with its frame as shown. + fn nearest_text( + &self, + point: [f32; 2], + keep: impl Fn(&TextOutline) -> bool, + ) -> Option<(onestore::ExGuid, [f32; 4])> { self.editor .visible_outlines() .chain( @@ -1107,6 +1116,7 @@ impl PageView { .caret_outline() .filter(|_| self.column.is_some()), ) + .filter(|source| keep(source)) .filter_map(|source| { let (geometry, origin) = self.text_geometry(source.id).ok()?; let rect = if self.column.is_some() { @@ -1122,10 +1132,10 @@ impl PageView { }; let dx = (rect[0] - point[0]).max(point[0] - rect[2]).max(0.0); let dy = (rect[1] - point[1]).max(point[1] - rect[3]).max(0.0); - Some((dx.hypot(dy), rect)) + Some((dx.hypot(dy), source.id, rect)) }) .min_by(|a, b| a.0.total_cmp(&b.0)) - .map(|(_, rect)| rect) + .map(|(_, id, rect)| (id, rect)) } fn move_selection(&mut self, movement: Movement, extend: bool) -> Result<()> { @@ -1611,18 +1621,10 @@ impl PageView { /// A fresh click places text 7 px above the pointer, on the grid. fn place_caret(&mut self, point: [f32; 2]) -> Result<()> { - if let Some(column) = &self.column { - let id = column - .outlines - .iter() - .rev() - .find(|(id, _, _)| { - self.editor - .visible_outlines() - .chain(self.editor.caret_outline()) - .any(|outline| outline.id == *id && !outline.title) - }) - .map(|(id, _, _)| *id); + if self.column.is_some() { + let id = self + .nearest_text(point, |outline| !outline.title) + .map(|(id, _)| id); if let Some(id) = id { self.editor.focus_outline(id)?; self.reconcile_reading(); diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index d7c1ac3658e4b9205b1f2f4374c14248185fe2a1..160c9a8b6ec12014f5482bc8f6b91807433435d7 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -3747,6 +3747,174 @@ fn reader_view(page: Page) -> PageView { view } +/// `reader_page` with its body as two columns side by side, the right one a little higher, +/// and a closing outline below both, stored in an order other than reading order. +fn reader_columns_page() -> (Page, [onestore::ExGuid; 3]) { + let mut page = reader_page(); + let mut engine = TextEngine::default(); + let mut outline = |text: &[&str], width, origin| { + TextOutline::new( + &mut engine, + TextDocument::new( + text.iter() + .map(|text| Paragraph::new((*text).into(), Default::default())) + .collect(), + ) + .unwrap(), + width, + origin, + ) + .unwrap() + .snapshot() + }; + let left = outline( + &[ + "Left column first, with words enough to wrap", + "Its second paragraph", + ], + 200.0, + [36.0, 240.0], + ); + let right = outline(&["Right column note"], 180.0, [300.0, 230.0]); + let below = outline(&["Closing outline below both"], 468.0, [36.0, 520.0]); + let ids = [left.id, right.id, below.id]; + page.objects.truncate(1); + page.objects.extend( + [below, right, left] + .into_iter() + .map(onestore::page::PageObject::Outline), + ); + (page, ids) +} + +#[test] +fn reader_stacks_several_outlines_in_reading_order_each_editable_where_it_is() { + let (page, [left, right, below]) = reader_columns_page(); + let mut view = reader_view(page); + let original = view.editor.page().unwrap(); + let title = view + .editor + .outlines() + .iter() + .find(|outline| outline.title) + .unwrap() + .id; + assert!(view.reading_status(200.0).0); + assert!(view.set_reading(Some(200.0))); + let column = |view: &PageView| { + view.column + .as_ref() + .unwrap() + .outlines + .iter() + .map(|(id, origin, shaped)| (*id, *origin, shaped.size[1])) + .collect::>() + }; + let stacked = column(&view); + assert_eq!( + stacked.iter().map(|(id, ..)| *id).collect::>(), + [title, left, right, below] + ); + for pair in stacked.windows(2) { + assert_eq!(pair[1].1[0], 36.0); + assert!(pair[1].1[1] >= pair[0].1[1] + pair[0].2 + 18.0); + } + let offset = view.scene.as_ref().unwrap().1; + let tap = |view: &mut PageView, id| { + let (_, origin, _) = column(view).into_iter().find(|(o, ..)| *o == id).unwrap(); + let point = [origin[0] + offset[0] + 4.0, origin[1] + offset[1] + 6.0]; + assert!(matches!(view.hit(point), Some(Hit::Text { id: hit, .. }) if hit == id)); + click(view, point, Instant::now()); + assert_eq!(view.editor.active_outline().id, id); + }; + tap(&mut view, right); + view.move_selection(Movement::DocumentEnd, false).unwrap(); + assert!(view.commit_text(" typed ๐Ÿ˜€".into()).unwrap().changed); + assert!(view.compose("ใ‹ใช".into(), None).unwrap().changed); + assert!(view.editor.marked_range().is_some()); + assert!(view.commit_text("ไปฎๅ".into()).unwrap().changed); + assert!(view.editor.marked_range().is_none()); + assert!(!view.editor.take_ops().unwrap().is_empty()); + let edited = view.editor.page().unwrap(); + let text = |page: &Page, id| { + page.objects + .iter() + .find_map(|object| match object { + onestore::page::PageObject::Outline(outline) if outline.id == id => Some( + outline + .paragraphs + .iter() + .filter_map(|paragraph| paragraph.text()) + .map(|text| text.text.text().to_owned()) + .collect::>() + .join("\n"), + ), + _ => None, + }) + .unwrap() + }; + assert_eq!(text(&edited, right), "Right column note typed ๐Ÿ˜€ไปฎๅ"); + for id in [left, below] { + assert_eq!(text(&edited, id), text(&original, id)); + } + let layout = |page: &Page, id| { + page.objects + .iter() + .find(|object| object.id() == id) + .unwrap() + .layout() + .clone() + }; + for id in [left, right, below] { + assert_eq!( + [layout(&edited, id).x, layout(&edited, id).y], + [layout(&original, id).x, layout(&original, id).y] + ); + } + let restacked = column(&view); + assert_eq!( + restacked.iter().map(|(id, ..)| *id).collect::>(), + [title, left, right, below] + ); + tap(&mut view, below); + tap(&mut view, left); + let history = view.editor.history_depth(); + while view.editor.history_depth()[0] > 0 { + assert!(view.undo(false).unwrap().changed); + } + assert_eq!(view.editor.page().unwrap(), original); + assert_eq!(view.editor.active_outline().id, right); + while view.editor.history_depth()[1] > 0 { + assert!(view.undo(true).unwrap().changed); + } + assert_eq!(view.editor.page().unwrap(), edited); + assert_eq!(view.editor.history_depth(), history); + assert!(view.column.is_some()); + view.primitives(COLORS).unwrap(); + // A tap between boxes goes to the nearer one. + let place = |view: &PageView, id| { + let (_, origin, height) = column(view).into_iter().find(|(o, ..)| *o == id).unwrap(); + [origin[1] + offset[1], origin[1] + offset[1] + height] + }; + for (id, y) in [ + (left, place(&view, left)[1] + 3.0), + (below, place(&view, below)[0] - 3.0), + ] { + click(&mut view, [offset[0] + 60.0, y], Instant::now()); + assert_eq!(view.editor.active_outline().id, id); + } + // An emptied box keeps its place. + tap(&mut view, right); + view.move_selection(Movement::DocumentStart, false).unwrap(); + view.move_selection(Movement::DocumentEnd, true).unwrap(); + let _ = view.key(&Key::Named(NamedKey::Backspace), None).unwrap(); + assert!(view.editor.active_outline().shown_text().is_empty()); + assert_eq!( + column(&view).iter().map(|(id, ..)| *id).collect::>(), + [title, left, right, below] + ); +} + #[test] fn reader_consumers_share_title_header_date_and_body_geometry() { let mut view = reader_view(reader_page()); @@ -4244,7 +4412,10 @@ fn reader_empty_title_only_and_remote_unsupported_content_follow_current_source( &mut engine, TextDocument::new(vec![Paragraph::new( "Remote annotation".into(), - Default::default(), + Format { + math: Some(true), + ..Default::default() + }, )]) .unwrap(), 120.0, diff --git a/crates/canvas/src/reading.rs b/crates/canvas/src/reading.rs index dae89ea8e16673d79b3d743f2b1b68623350882c..4bcf3767a4985e30572cb5ee51a3543fb55b96ea 100644 --- a/crates/canvas/src/reading.rs +++ b/crates/canvas/src/reading.rs @@ -51,26 +51,32 @@ pub(crate) fn geometry<'a>( #[cfg(feature = "interaction")] impl Column { + /// The page's editable outlines stacked in reading order, title and its header first, + /// or None where something on the page would not reflow. pub(crate) fn build( editor: &CanvasEditor, scene: Option<&crate::gpu::page::PageScene>, width: f32, engine: &mut TextEngine, ) -> Result, crate::layout::LayoutError> { - use crate::outline::{OutlineLayout, ParagraphLayout}; if !width.is_finite() || width <= 0.0 { return Err(crate::layout::LayoutError::InvalidWidth); } if editor.rtl() { return Ok(None); } - let title = editor + let (titles, body): (Vec<_>, Vec<_>) = editor .visible_outlines() - .find(|outline| outline.title) - .map(|outline| outline.id); + .chain(editor.caret_outline()) + .partition(|outline| outline.title); + let title = match titles[..] { + [] => None, + [title] => Some(title), + _ => return Ok(None), + }; if editor.objects.iter().any(|object| { if let Content::Date { below_title } | Content::Outline { below_title, .. } = object - && (below_title.is_none() || *below_title != title) + && (below_title.is_none() || *below_title != title.map(|title| title.id)) { return true; } @@ -102,100 +108,120 @@ impl Column { { return Ok(None); } - let mut sources: Vec<_> = editor - .visible_outlines() - .chain(editor.caret_outline()) + let tops = editor + .outlines() + .iter() + .map(|outline| (outline.id, root_tops(outline))) .collect(); - if sources.iter().filter(|outline| !outline.title).count() > 1 - || sources.iter().filter(|outline| outline.title).count() > 1 - { - return Ok(None); - } - sources.sort_by_key(|outline| !outline.title); + // Emptied outlines, and one being started, keep their place while typing. + let ordered = order( + blocks( + editor, + editor.visible_outlines().chain(editor.caret_outline()), + ), + &tops, + &mut Vec::new(), + ); + let rank = |id: ExGuid| { + ordered + .iter() + .position(|block| block.members.contains(&id)) + .unwrap_or(ordered.len()) + }; + let mut body: Vec<_> = body + .into_iter() + .map(|outline| (rank(outline.id), outline)) + .collect(); + body.sort_by(|(a_rank, a), (b_rank, b)| { + let ([ax, ay], [bx, by]) = (a.origin(), b.origin()); + a_rank + .cmp(b_rank) + .then(ay.total_cmp(&by)) + .then(ax.total_cmp(&bx)) + }); let mut column = Self { width, outlines: Vec::new(), }; let mut top = 14.4; - for source in sources { - if source - .document() - .nodes() - .iter() - .any(|node| !matches!(node.content, ParagraphContent::Text(_))) - { + if let Some(title) = title { + let Some(layout) = column_layout(title, editor, width, engine)? else { return Ok(None); - } - if source.document().paragraphs().any(|text| { - text.spans() - .iter() - .any(|span| span.format.math == Some(true) || span.format.math_object.is_some()) - }) { - return Ok(None); - } - let shaped = OutlineLayout::flow( - source.document().nodes().iter(), - &source.indents, - width, - false, - 0, - None, - &mut |node, previous, width, indents| { - ParagraphLayout::shape( - engine, - node, - previous, - width, - indents, - &editor.definitions, - ) - }, - )?; - if shaped.size[0] > width + 1.0 - || shaped.paragraphs.iter().any(|paragraph| { - paragraph.origin[0] < 0.0 - || paragraph.origin[0] - + paragraph - .text - .lines() - .map(|(line, _)| { - line.metrics().advance - line.metrics().trailing_whitespace - }) - .fold(0.0_f32, f32::max) - > width + 1.0 - }) - { - return Ok(None); - } - let origin = [36.0, top]; - top += shaped.size[1] + GAP; - column.outlines.push((source.id, origin, shaped)); + }; + top += layout.size[1] + GAP; + column.outlines.push((title.id, [36.0, 14.4], layout)); } if let Some(scene) = scene { - let mut bottom = column - .outlines - .iter() - .find(|(id, _, _)| Some(*id) == title) - .map(|(_, origin, shaped)| origin[1] + shaped.size[1] + GAP) - .unwrap_or(14.4); for (rect, _) in scene.content_bounds(editor, Some(&column)) { if rect[0] < 36.0 || rect[2] > 36.0 + width + 1.0 { return Ok(None); } - bottom = bottom.max(rect[3] + GAP); - } - if let Some((_, origin, _)) = column - .outlines - .iter_mut() - .find(|(id, _, _)| Some(*id) != title) - { - origin[1] = bottom; + top = top.max(rect[3] + GAP); } } + for (_, source) in body { + let Some(layout) = column_layout(source, editor, width, engine)? else { + return Ok(None); + }; + let height = layout.size[1]; + column.outlines.push((source.id, [36.0, top], layout)); + top += height + GAP; + } Ok(Some(column)) } } +/// `source` wrapped to a column `width` points wide, or None where it would not fit. +#[cfg(feature = "interaction")] +fn column_layout( + source: &TextOutline, + editor: &CanvasEditor, + width: f32, + engine: &mut TextEngine, +) -> Result, crate::layout::LayoutError> { + use crate::outline::{OutlineLayout, ParagraphLayout}; + if source + .document() + .nodes() + .iter() + .any(|node| !matches!(node.content, ParagraphContent::Text(_))) + { + return Ok(None); + } + if source.document().paragraphs().any(|text| { + text.spans() + .iter() + .any(|span| span.format.math == Some(true) || span.format.math_object.is_some()) + }) { + return Ok(None); + } + let layout = OutlineLayout::flow( + source.document().nodes().iter(), + &source.indents, + width, + false, + 0, + None, + &mut |node, previous, width, indents| { + ParagraphLayout::shape(engine, node, previous, width, indents, &editor.definitions) + }, + )?; + let fits = layout.size[0] <= width + 1.0 + && layout.paragraphs.iter().all(|paragraph| { + paragraph.origin[0] >= 0.0 + && paragraph.origin[0] + + paragraph + .text + .lines() + .map(|(line, _)| { + line.metrics().advance - line.metrics().trailing_whitespace + }) + .fold(0.0_f32, f32::max) + <= width + 1.0 + }); + Ok(fits.then_some(layout)) +} + /// Points between blocks in the column: OneNote's grid row. const GAP: f32 = 18.0; /// Strokes this near one another are one drawing, as handwriting is many strokes. @@ -285,7 +311,13 @@ impl Verdict { /// `page` read into a column `column` points wide. pub fn read(page: &Page, column: f32, engine: &mut TextEngine) -> Result { let editor = CanvasEditor::from_page(page.clone(), engine)?; - let blocks = blocks(&editor); + let blocks = blocks( + &editor, + editor + .outlines() + .iter() + .filter(|outline| !outline.is_empty()), + ); let refused = |refusal, blocks| Reading { verdict: Verdict::Refused(refusal), blocks, @@ -425,12 +457,14 @@ fn root_tops(outline: &TextOutline) -> Vec { /// lie within them. type Item = (ExGuid, Kind, [f32; 4], Vec<[f32; 4]>); -/// The page's content as laid out, overlapping pieces and nearby strokes joined. -fn blocks(editor: &CanvasEditor) -> Vec { - let mut items: Vec = editor - .outlines() - .iter() - .filter(|outline| !outline.title && !outline.is_empty()) +/// The page's content as laid out, `outlines` among it, overlapping pieces and nearby +/// strokes joined. +fn blocks<'a>( + editor: &CanvasEditor, + outlines: impl Iterator, +) -> Vec { + let mut items: Vec = outlines + .filter(|outline| !outline.title) .map(|outline| { let b = outline.bounds(); let bounds = [b.x0, b.y0, b.x1, b.y1].map(|v| v as f32); diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index e827e86d4f9a45254e73c16f3fb33242a1f8e7f1..c2ee82634cfdbf1476f0eedecae98a760263156c 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -3317,6 +3317,108 @@ fn reader_native_selection_commits_marked_ops_and_keeps_the_original_offline_edi assert_eq!(std::fs::read(file).unwrap(), canonical); } +#[test] +fn reader_stacks_text_boxes_and_stores_an_edit_in_the_second_one() { + let (directory, section) = features(); + let file = directory.path().join("Features.one"); + let space = section.new_page(None, "Mon", "6:14 AM").unwrap(); + let (page, read_only) = section.shared.page(space).unwrap(); + let mut view = View { + canvas: ManuallyDrop::new(Canvas::new(space, page, [720, 1280], 2.0).unwrap()), + presentation: None, + frame: Arc::default(), + section: Arc::clone(§ion.shared), + read_only, + reload_pending: false, + local_edits: Arc::new(Mutex::new(LocalEdits::default())), + }; + view.canvas.set_transform(1.0, [0.0; 2]); + assert!(sb_view_focus_title(&mut view)); + assert!(unsafe { sb_insert(&mut view, c"Two boxes".as_ptr()) }); + let mut boxes = Vec::new(); + for (text, at) in [(c"Lower box", [72.0, 360.0]), (c"Upper box", [72.0, 200.0])] { + assert!(sb_view_press(&mut view, at[0] * POINT, at[1] * POINT)); + assert!(sb_view_release(&mut view)); + assert!(unsafe { sb_insert(&mut view, text.as_ptr()) }); + boxes.push(view.canvas.active().id); + } + let [lower, upper] = boxes[..] else { + unreachable!() + }; + assert_ne!(lower, upper); + let mut anchor = [0.0; 2]; + assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor)); + let start = |view: &View, id| { + view.canvas + .page + .caret_at( + id, + TextPosition { + paragraph: 0, + offset: 0, + }, + Affinity::Downstream, + 0.0, + ) + .unwrap() + }; + let [first, second] = [start(&view, upper), start(&view, lower)]; + assert_eq!(first.x0, second.x0); + assert!(first.y1 < second.y0); + assert!(sb_view_press( + &mut view, + second.x0 as f32 * POINT + 1.0, + (second.y0 + second.y1) as f32 / 2.0 * POINT + )); + assert!(sb_view_release(&mut view)); + assert_eq!(view.canvas.active().id, lower); + let end = view.canvas.length(); + assert!(sb_select(&mut view, end, end)); + assert!(unsafe { sb_insert(&mut view, c", edited in reading ๐Ÿ˜€".as_ptr()) }); + assert_eq!( + view.canvas.active().shown_text(), + "Lower box, edited in reading ๐Ÿ˜€" + ); + let mut status = sb_view_reading(&view, 240.0); + let json = serde_json::from_str::(&string(status)).unwrap(); + unsafe { sb_string_free(status) }; + assert_eq!(json["shown"], true); + assert!(section.flush(Duration::from_secs(20))); + let stored = stored_text(&file, space); + assert!(stored.contains("Upper box")); + assert!(stored.contains("Lower box, edited in reading ๐Ÿ˜€")); + assert!(sb_view_undo(&mut view, false)); + assert_eq!(view.canvas.active().shown_text(), "Lower box"); + assert!(sb_view_undo(&mut view, true)); + assert!(!sb_view_set_reading(&mut view, 0.0, &mut anchor)); + status = sb_view_reading(&view, 240.0); + let json = serde_json::from_str::(&string(status)).unwrap(); + unsafe { sb_string_free(status) }; + assert_eq!( + json, + serde_json::json!({ "offered": true, "shown": false, "verdict": "Reflows" }) + ); + assert!(section.flush(Duration::from_secs(20))); + let shared = Arc::clone(&view.section); + drop(view); + drop(section); + Arc::into_inner(shared).unwrap().section.close().unwrap(); + let arena = onestore::Arena::default(); + let cold = onestore::Section::open(&arena, onestore::read_file(&file).unwrap()).unwrap(); + let mut engine = pooled_engine(); + let cold = + canvas::editor::CanvasEditor::from_page(cold.page(space).unwrap(), &mut engine).unwrap(); + pool_engine(engine); + let texts: Vec<_> = cold + .outlines() + .iter() + .filter(|outline| !outline.title) + .map(|outline| outline.shown_text()) + .collect(); + assert!(texts.contains(&"Upper box".to_string())); + assert!(texts.contains(&"Lower box, edited in reading ๐Ÿ˜€".to_string())); +} + #[test] fn reader_local_apply_fence_tracks_overlapping_replies_and_view_lifetime() { use std::sync::mpsc; -- 2.54.0