From b5d8955327de829054dd337a86eed6fc6582be62 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 24 Sep 2026 01:26:06 -0700 Subject: [PATCH] feat: select, move and resize page pictures as OneNote does Pictures were drawn but took no hits, so clicks fell through to the page. Observed in OneNote 2010 on "av: casual night in the trees": a click selects a picture with a dashed border 5 px outside it, eight handles on that border and a 25% tint; dragging the body moves it on the page grid; edge handles stretch one axis and corner handles keep the aspect ratio at the larger of the two scales, anchored at the opposite corner (a corner drag of (-75, 30.75) pt gave 281.83 x 169.35 from 333 x 200.1). Background pictures pass clicks through. The editor gains image_placement and place_image, whose move or resize is one undo step and marks a resize as set by the user, as the page writer stores it. The scene reports picture hits in paint order and can draw one picture at a previewed rectangle. The host replaces its read-only focus with ObjectFocus, so a selected picture hides the caret and suspends typing as a placeholder does, while keeping undo. Assisted-by: claude-opus-5 --- crates/canvas/src/editor.rs | 146 +++++++++++ crates/canvas/src/gpu/page.rs | 106 +++++++- crates/snowbound/src/main.rs | 448 ++++++++++++++++++++++++++++------ tools/canvas/README.md | 4 +- 4 files changed, 612 insertions(+), 92 deletions(-) diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index e62ec2adf0c67785c8119a17d40ccef7a213870c..c392e2579d005ad05c2e6297b77cdbefd6ee10b6 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -620,6 +620,10 @@ enum History { outline: ExGuid, layout: onestore::document::Layout, }, + Image { + image: ExGuid, + layout: onestore::document::Layout, + }, Remove { outline: onestore::ExGuid, focus: RestoreFocus, @@ -1271,6 +1275,62 @@ impl CanvasEditor { Ok(()) } + /// Origin and size of a picture the user can select; OneNote passes clicks through + /// backgrounds. + pub fn image_placement(&self, id: ExGuid) -> Option<([f32; 2], [f32; 2])> { + let layout = &self.image(id)?.layout; + Some(( + [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)], + [layout.max_width?, layout.max_height?], + )) + } + + fn image(&self, id: ExGuid) -> Option<&onestore::page::Image> { + self.objects.iter().find_map(|object| match object { + page::Content::Image(image) if image.id == id && !image.background => Some(image), + _ => None, + }) + } + + fn image_mut(&mut self, id: ExGuid) -> Option<&mut onestore::page::Image> { + self.objects.iter_mut().find_map(|object| match object { + page::Content::Image(image) if image.id == id && !image.background => Some(image), + _ => None, + }) + } + + /// Moves a picture, and resizes it when `size` differs from the stored size. + pub fn place_image( + &mut self, + id: ExGuid, + origin: [f32; 2], + size: [f32; 2], + ) -> Result<(), EditError> { + if !origin.iter().chain(&size).all(|value| value.is_finite()) + || size.iter().any(|value| *value <= 0.0) + { + return Err(EditError::InvalidRange); + } + let image = self.image(id).ok_or(EditError::InvalidRange)?; + let mut layout = image.layout.clone(); + [layout.x, layout.y] = origin.map(Some); + if [layout.max_width, layout.max_height] != size.map(Some) { + [layout.max_width, layout.max_height] = size.map(Some); + layout.width_set_by_user = Some(true); + } + if layout == image.layout { + return Ok(()); + } + self.finish_composition(); + let previous = std::mem::replace(&mut self.image_mut(id).unwrap().layout, layout); + self.undo.push(History::Image { + image: id, + layout: previous, + }); + self.redo.clear(); + Ok(()) + } + pub fn selection(&self) -> Selection { self.active_outline().selection } @@ -2249,6 +2309,7 @@ impl CanvasEditor { | History::Layout { outline, .. } => { self.outlines.iter().any(|item| item.id == *outline) } + History::Image { image, .. } => self.image(*image).is_some(), History::Remove { outline, focus } => { self.outlines.iter().any(|item| item.id == *outline) && match focus { @@ -2396,6 +2457,10 @@ impl CanvasEditor { layout: previous, } } + History::Image { image, layout } => History::Image { + image, + layout: std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout), + }, History::Remove { outline, focus } => { let index = self .outlines @@ -4330,6 +4395,87 @@ mod tests { } } + #[test] + fn pictures_move_and_resize_through_history_and_backgrounds_stay_put() { + use onestore::page::{Image, Page, PageObject}; + let mut engine = TextEngine::default(); + let [picture, background] = [false, true].map(|background| Image { + size: None, + id: onestore::page::text::new_id().unwrap(), + layout: onestore::document::Layout { + x: Some(468.75), + y: Some(86.4), + max_width: Some(333.0), + max_height: Some(200.1), + ..Default::default() + }, + bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())), + alt: None, + background, + }); + let (id, background_id) = (picture.id, background.id); + let mut editor = CanvasEditor::from_page( + Page { + title: String::new(), + identity: None, + created: None, + margin_origin: [36.0, 14.4], + definitions: BTreeMap::new(), + objects: vec![PageObject::Image(background), PageObject::Image(picture)], + }, + &mut engine, + ) + .unwrap(); + let stored = |editor: &CanvasEditor| { + editor + .page() + .unwrap() + .objects + .into_iter() + .find_map(|object| match object { + PageObject::Image(image) if image.id == id => Some(image.layout), + _ => None, + }) + .unwrap() + }; + let original = stored(&editor); + assert_eq!( + editor.image_placement(id), + Some(([468.75, 86.4], [333.0, 200.1])) + ); + assert!(editor.image_placement(background_id).is_none()); + assert_eq!( + editor.place_image(background_id, [0.0; 2], [10.0; 2]), + Err(EditError::InvalidRange) + ); + assert_eq!( + editor.place_image(id, [0.0; 2], [0.0, 10.0]), + Err(EditError::InvalidRange) + ); + editor + .place_image(id, [513.75, 104.4], [333.0, 200.1]) + .unwrap(); + let moved = stored(&editor); + assert_eq!([moved.x, moved.y], [Some(513.75), Some(104.4)]); + assert_eq!(moved.width_set_by_user, None); + editor + .place_image(id, [513.75, 104.4], [281.8, 169.4]) + .unwrap(); + let resized = stored(&editor); + assert_eq!( + [resized.max_width, resized.max_height], + [Some(281.8), Some(169.4)] + ); + assert_eq!(resized.width_set_by_user, Some(true)); + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!(stored(&editor), moved); + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!(stored(&editor), original); + assert!(editor.redo(&mut engine).unwrap()); + assert!(editor.redo(&mut engine).unwrap()); + assert_eq!(stored(&editor), resized); + } + #[test] fn title_flow_moves_page_objects_atomically_and_cancels_composition() { use onestore::page::{Image, Page, PageObject, Title}; diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 8fdbbfb399444f14535a9e98f84ce41a1d45df44..0ebe062029f95ef8c77271be67b9e81661b201ef 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -20,6 +20,7 @@ pub enum SceneHit { Outline(T), Date(DateField), ReadOnly(usize), + Image(onestore::ExGuid), } fn outline_origin( @@ -283,6 +284,19 @@ impl PageScene { return Some(SceneHit::ReadOnly(readonly)); } } + Content::Image(source) if !source.background => { + let [x, y] = [ + source.layout.x.unwrap_or(0.0), + source.layout.y.unwrap_or(0.0), + ]; + if let (Some(width), Some(height)) = + (source.layout.max_width, source.layout.max_height) + && (x..=x + width).contains(&point[0]) + && (y..=y + height).contains(&point[1]) + { + return Some(SceneHit::Image(source.id)); + } + } Content::Outline { .. } | Content::Image(_) => {} } } @@ -295,16 +309,18 @@ impl PageScene { primitives: &mut Vec>, offset: [f32; 2], ) -> Result<(), SceneError> { - self.append_primitives_with(primitives, offset, None, |_, _, _| { + self.append_primitives_with(primitives, offset, None, None, |_, _, _| { Err(SceneError::MissingOutline) }) } + /// `moving` draws one picture at a previewed rectangle instead of its stored layout. pub fn append_primitives_with<'a, E: From>( &'a self, primitives: &mut Vec>, offset: [f32; 2], editor: Option<&'a CanvasEditor>, + moving: Option<(onestore::ExGuid, [f32; 4])>, mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec>) -> Result<(), E>, ) -> Result<(), E> { for content in self.objects(editor)? { @@ -373,14 +389,22 @@ impl PageScene { .images .get(&source.id) .ok_or(SceneError::MissingImage)?, - rect: [ - object_origin[0], - object_origin[1], - object_origin[0] - + source.layout.max_width.ok_or(SceneError::MissingImage)?, - object_origin[1] - + source.layout.max_height.ok_or(SceneError::MissingImage)?, - ], + rect: match moving { + Some((id, [x0, y0, x1, y1])) if id == source.id => [ + x0 + offset[0], + y0 + offset[1], + x1 + offset[0], + y1 + offset[1], + ], + _ => [ + object_origin[0], + object_origin[1], + object_origin[0] + + source.layout.max_width.ok_or(SceneError::MissingImage)?, + object_origin[1] + + source.layout.max_height.ok_or(SceneError::MissingImage)?, + ], + }, }), Content::Outline { .. } | Content::Date { .. } => { let outline = match content { @@ -930,6 +954,7 @@ mod tests { &mut primitives, [0.0; 2], Some(editor), + None, |_, _, _| Ok(()), ) .unwrap(); @@ -1327,6 +1352,7 @@ mod tests { &mut primitives, [0.0; 2], Some(editor), + None, |id, offset, primitives| { let outline = editor .outlines() @@ -1561,4 +1587,66 @@ mod tests { assert_eq!(first.pixels.id(), second.pixels.id()); assert_eq!(first.pixels.as_ref(), [255, 0, 0, 255]); } + + #[test] + fn foreground_pictures_take_hits_and_draw_at_their_moving_preview() { + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut bytes, 1, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .unwrap() + .write_image_data(&[17, 34, 51, 255]) + .unwrap(); + } + let image = |x: f32, background| Image { + size: None, + id: onestore::page::text::new_id().unwrap(), + layout: Layout { + x: Some(x), + y: Some(0.0), + max_width: Some(100.0), + max_height: Some(100.0), + ..Default::default() + }, + bytes: Some(Arc::from(bytes.clone())), + alt: None, + background, + }; + let [background, picture] = [image(0.0, true), image(50.0, false)]; + let id = picture.id; + let page = Page { + identity: None, + created: None, + title: String::new(), + margin_origin: [0.0; 2], + definitions: BTreeMap::new(), + objects: vec![PageObject::Image(background), PageObject::Image(picture)], + }; + let mut engine = TextEngine::default(); + let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + let hit = |x| scene.hit_test::<()>([x, 10.0], Some(&editor), |_| None); + assert_eq!(hit(60.0), Some(SceneHit::Image(id))); + assert_eq!(hit(10.0), None); + let mut primitives = Vec::new(); + scene + .append_primitives_with::( + &mut primitives, + [0.0; 2], + Some(&editor), + Some((id, [70.0, 5.0, 120.0, 55.0])), + |_, _, _| Ok(()), + ) + .unwrap(); + let rects: Vec<_> = primitives + .iter() + .filter_map(|primitive| match primitive { + Primitive::Image { rect, .. } => Some(*rect), + _ => None, + }) + .collect(); + assert_eq!(rects, [[0.0, 0.0, 100.0, 100.0], [70.0, 5.0, 120.0, 55.0]]); + } } diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 976248183a5eb81fe38dbac383a56d5be3e4e00d..fa100bb08d36da7aa9c51c6d7af672b2d994e130 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -123,6 +123,13 @@ enum Drag { grab: [f32; 2], pending_press: Option<[f32; 2]>, }, + Image { + id: onestore::ExGuid, + handle: [i8; 2], + /// Document point of the press. + press: [f32; 2], + pending_press: Option<[f32; 2]>, + }, } #[derive(Clone, Copy)] @@ -130,6 +137,24 @@ enum PointerFeedback<'a> { Hover(onestore::ExGuid), Move(onestore::ExGuid, [f32; 2]), Resize(&'a TextOutline), + /// A picture being moved or resized, drawn at this origin and size. + Image(onestore::ExGuid, [f32; 2], [f32; 2]), +} + +/// A non-text object holding focus, which hides the text caret and suspends typing. +#[derive(Clone, Copy, Debug, PartialEq)] +enum ObjectFocus { + ReadOnly(usize), + Image(onestore::ExGuid), +} + +impl ObjectFocus { + fn read_only(self) -> Option { + match self { + Self::ReadOnly(index) => Some(index), + Self::Image(_) => None, + } + } } struct State { @@ -151,7 +176,7 @@ struct State { pointer_inside: bool, last_click: Option<(Instant, [f32; 2], u8)>, drag: Option, - read_only_focus: Option, + object_focus: Option, modifiers: ModifiersState, focused: bool, occluded: bool, @@ -310,7 +335,7 @@ impl State { pointer_inside: false, last_click: None, drag: None, - read_only_focus: None, + object_focus: None, modifiers: ModifiersState::empty(), focused: true, occluded: false, @@ -352,15 +377,75 @@ impl State { )) } - fn set_read_only_focus(&mut self, index: Option) { - if self.read_only_focus != index { + fn image_preview(&self) -> Option<(onestore::ExGuid, [f32; 2], [f32; 2])> { + let Drag::Image { + id, + handle, + press, + pending_press: None, + } = self.drag.as_ref()? + else { + return None; + }; + let (origin, size) = self.editor.image_placement(*id)?; + let point = self.viewport.document_point(self.pointer); + let delta = [point[0] - press[0], point[1] - press[1]]; + if *handle != [0, 0] { + let (origin, size) = resize_image(origin, size, *handle, delta); + return Some((*id, origin, size)); + } + let origin = [origin[0] + delta[0], origin[1] + delta[1]]; + let origin = if self.modifiers.alt_key() { + origin + } else { + snap_to_grid(origin) + }; + Some((*id, origin, size)) + } + + /// The selected picture's handles lie above the page; its body keeps its paint order. + fn hit_test(&self, point: [f32; 2]) -> Option { + let pixel = self.display_scale / self.viewport.scale; + if let Some(ObjectFocus::Image(id)) = self.object_focus + && let Some((origin, size)) = self.editor.image_placement(id) + && let Some(handle) = image_handle_at(image_rect(origin, size), pixel, point) + { + return Some(Hit::Image { id, handle }); + } + page_hit_test(&self.editor, self.scene.as_ref(), point, pixel) + } + + fn set_object_focus(&mut self, focus: Option) { + if self.object_focus != focus { self.editor.finish_composition(); self.drag = None; - self.read_only_focus = index; - self.window.set_ime_allowed(index.is_none()); + self.object_focus = focus; + self.window.set_ime_allowed(focus.is_none()); } } + /// Page coordinates of a focused object. + fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] { + let (scene, offset) = self.scene.as_ref().unwrap(); + let [x0, y0, x1, y1] = match focus { + ObjectFocus::ReadOnly(index) => scene + .read_only(Some(&self.editor)) + .nth(index) + .unwrap() + .rect(), + ObjectFocus::Image(id) => { + let (origin, size) = self.editor.image_placement(id).unwrap(); + image_rect(origin, size) + } + }; + [ + x0 + offset[0], + y0 + offset[1], + x1 + offset[0], + y1 + offset[1], + ] + } + fn changed(&mut self) -> Result<(), Box> { self.scroll().clamp(&mut self.viewport); self.caret = true; @@ -446,7 +531,7 @@ impl State { self.editor = editor; self.scene = Some((scene, [0.0; 2])); self.drag = None; - self.read_only_focus = None; + self.object_focus = None; self.window.set_title(&session.window_title()); self.update_accessibility()?; self.window.request_redraw(); @@ -535,7 +620,7 @@ impl State { self.scene.as_ref(), &self.editor, self.viewport, - self.read_only_focus, + self.object_focus.and_then(ObjectFocus::read_only), ); update } @@ -573,7 +658,7 @@ impl State { } if let Some(index) = self.accessibility.read_only_for_node(request.target_node) { if request.action == Action::Focus { - self.set_read_only_focus(Some(index)); + self.set_object_focus(Some(ObjectFocus::ReadOnly(index))); self.window.focus_window(); self.reveal_focus()?; self.changed()?; @@ -605,7 +690,7 @@ impl State { } _ => return Ok(()), } - self.set_read_only_focus(None); + self.set_object_focus(None); self.reveal_focus()?; self.changed() } @@ -614,19 +699,9 @@ impl State { if self.viewport.size.contains(&0) { return Ok(()); } - let rect = if let Some(index) = self.read_only_focus { - let (scene, offset) = self.scene.as_ref().unwrap(); - let [x0, y0, x1, y1] = scene - .read_only(Some(&self.editor)) - .nth(index) - .unwrap() - .rect(); - parley::BoundingBox { - x0: f64::from(x0 + offset[0]), - y0: f64::from(y0 + offset[1]), - x1: f64::from(x1 + offset[0]), - y1: f64::from(y1 + offset[1]), - } + let rect = if let Some(focus) = self.object_focus { + let [x0, y0, x1, y1] = self.object_rect(focus).map(f64::from); + parley::BoundingBox { x0, y0, x1, y1 } } else { let mut rect = self.editor.caret(1.0)?; let origin = self.editor.active_outline().origin(); @@ -647,7 +722,7 @@ impl State { [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis]; let outline_start = outline_start.min(start); let outline_end = outline_end.max(end); - if self.read_only_focus.is_none() + if self.object_focus.is_none() && (outline_end - outline_start) * f64::from(self.viewport.scale) <= size - margin * 2.0 { @@ -759,6 +834,10 @@ impl State { _ => self .preview() .map(|(id, origin)| PointerFeedback::Move(id, origin)) + .or_else(|| { + self.image_preview() + .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size)) + }) .or_else(|| { if !self.pointer_inside { return None; @@ -778,10 +857,13 @@ impl State { } }), }, - self.read_only_focus, + self.object_focus, self.caret && self.focused - && !matches!(self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. })), + && !matches!( + self.drag, + Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) + ), self.viewport.scale, self.display_scale, )?; @@ -807,12 +889,15 @@ impl State { let shift = self.modifiers.shift_key(); let command = self.modifiers.super_key(); let option = self.modifiers.alt_key(); - if self.read_only_focus.is_some() { - if !read_only_shortcut(key, self.modifiers) { + if let Some(focus) = self.object_focus { + let undo = matches!(focus, ObjectFocus::Image(_)) + && command + && matches!(key, Key::Character(value) if value.eq_ignore_ascii_case("z")); + if !undo && !read_only_shortcut(key, self.modifiers) { return Ok(()); } if key == &Key::Named(NamedKey::Escape) { - self.set_read_only_focus(None); + self.set_object_focus(None); self.reveal_focus()?; return self.changed(); } @@ -832,7 +917,10 @@ impl State { _ => {} } } - if matches!(self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. })) { + if matches!( + self.drag, + Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) + ) { if matches!( key, Key::Named( @@ -881,14 +969,17 @@ impl State { if count == 0 { return self.changed(); } - let index = self.read_only_focus.map_or_else( - || { - outlines - .iter() - .position(|outline| outline.id == self.editor.active_outline().id) - }, - |index| Some(outlines.len() + index), - ); + let index = self + .object_focus + .and_then(ObjectFocus::read_only) + .map_or_else( + || { + outlines + .iter() + .position(|outline| outline.id == self.editor.active_outline().id) + }, + |index| Some(outlines.len() + index), + ); let next = index.map_or(if shift { count - 1 } else { 0 }, |index| { if shift { (index + count - 1) % count @@ -898,9 +989,9 @@ impl State { }); if next < outlines.len() { self.editor.focus_outline(outlines[next].id)?; - self.set_read_only_focus(None); + self.set_object_focus(None); } else { - self.set_read_only_focus(Some(next - outlines.len())); + self.set_object_focus(Some(ObjectFocus::ReadOnly(next - outlines.len()))); } self.reveal_focus()?; return self.changed(); @@ -908,14 +999,9 @@ impl State { if command && let Key::Character(key) = key { match key.to_lowercase().as_str() { "n" if shift => { - let position = if let Some(index) = self.read_only_focus { - let (scene, offset) = self.scene.as_ref().unwrap(); - let rect = scene - .read_only(Some(&self.editor)) - .nth(index) - .unwrap() - .rect(); - [rect[2] + offset[0] + 24.0, rect[1] + offset[1]] + let position = if let Some(focus) = self.object_focus { + let rect = self.object_rect(focus); + [rect[2] + 24.0, rect[1]] } else { let bounds = self.editor.active_outline().bounds(); [bounds.x1 as f32 + 24.0, bounds.y0 as f32] @@ -925,7 +1011,7 @@ impl State { snap_to_grid(position), DEFAULT_OUTLINE_WIDTH, )?; - self.set_read_only_focus(None); + self.set_object_focus(None); } "a" => self.editor.select_all()?, "z" => { @@ -1137,7 +1223,7 @@ impl ApplicationHandler for App { let event = match event { UserEvent::InsertText(text) => { if let Some(state) = &mut self.state - && state.read_only_focus.is_none() + && state.object_focus.is_none() && !text.is_empty() && !text.chars().any(char::is_control) { @@ -1282,12 +1368,7 @@ impl ApplicationHandler for App { WindowEvent::CursorMoved { position, .. } => { state.pointer_inside = true; state.pointer = [position.x as f32, position.y as f32]; - let hit = page_hit_test( - &state.editor, - state.scene.as_ref(), - state.viewport.document_point(state.pointer), - state.display_scale / state.viewport.scale, - ); + let hit = state.hit_test(state.viewport.document_point(state.pointer)); let scrollbar = state .scroll() .hit_test(state.viewport, state.display_scale, state.pointer) @@ -1295,6 +1376,8 @@ impl ApplicationHandler for App { state.window.set_cursor(match (&state.drag, hit) { (Some(Drag::Scrollbar { .. }), _) => winit::window::CursorIcon::Default, (None, _) if scrollbar => winit::window::CursorIcon::Default, + (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle), + (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle), (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => { winit::window::CursorIcon::EwResize } @@ -1330,7 +1413,9 @@ impl ApplicationHandler for App { .select(drag_selection(*anchor, target, *unit))?; state.changed()?; } - Some(Drag::Outline { pending_press, .. }) => { + Some( + Drag::Outline { pending_press, .. } | Drag::Image { pending_press, .. }, + ) => { if pending_press.is_some_and(|press| { (0..2).any(|axis| { (state.pointer[axis] - press[axis]).abs() @@ -1387,18 +1472,22 @@ impl ApplicationHandler for App { if scrollbar.is_some() { state.drag = scrollbar; } else { - match page_hit_test( - &state.editor, - state.scene.as_ref(), - point, - state.display_scale / state.viewport.scale, - ) { + match state.hit_test(point) { Some(Hit::Date(field)) => state.edit_date(field)?, Some(Hit::ReadOnly(index)) => { - state.set_read_only_focus(Some(index)) + state.set_object_focus(Some(ObjectFocus::ReadOnly(index))) + } + Some(Hit::Image { id, handle }) => { + state.set_object_focus(Some(ObjectFocus::Image(id))); + state.drag = Some(Drag::Image { + id, + handle, + press: point, + pending_press: Some(state.pointer), + }); } Some(Hit::Handle { id, grab }) => { - state.set_read_only_focus(None); + state.set_object_focus(None); state.editor.focus_outline(id)?; state.drag = Some(Drag::Outline { id, @@ -1407,7 +1496,7 @@ impl ApplicationHandler for App { }); } Some(Hit::Resize { id, grab }) => { - state.set_read_only_focus(None); + state.set_object_focus(None); state.editor.focus_outline(id)?; state.drag = Some(Drag::Resize { outline: None, @@ -1416,9 +1505,9 @@ impl ApplicationHandler for App { } Some(Hit::Text { id, point }) => { let extend = state.modifiers.shift_key() - && state.read_only_focus.is_none() + && state.object_focus.is_none() && id == state.editor.active_outline().id; - state.set_read_only_focus(None); + state.set_object_focus(None); state.editor.focus_outline(id)?; let previous = state.editor.selection(); state.editor.select_below(&mut state.engine, id, point)?; @@ -1464,7 +1553,7 @@ impl ApplicationHandler for App { position, DEFAULT_OUTLINE_WIDTH, )?; - state.set_read_only_focus(None); + state.set_object_focus(None); state.drag = Some(Drag::Text { anchor: state.editor.selection(), unit: SelectionUnit::Grapheme, @@ -1474,6 +1563,9 @@ impl ApplicationHandler for App { } } else { let preview = state.preview(); + if let Some((id, origin, size)) = state.image_preview() { + state.editor.place_image(id, origin, size)?; + } if let Some(Drag::Resize { outline: Some(outline), .. @@ -1508,7 +1600,7 @@ impl ApplicationHandler for App { { state.key(&event.logical_key, event.text.as_deref())?; } - WindowEvent::Ime(_) if state.read_only_focus.is_some() => {} + WindowEvent::Ime(_) if state.object_focus.is_some() => {} WindowEvent::Ime(Ime::Preedit(text, cursor)) => { if text.is_empty() { state.editor.cancel_composition(&mut state.engine)?; @@ -1558,7 +1650,7 @@ impl ApplicationHandler for App { return; }; let [anchor, focus] = state.editor.selection().positions; - if state.focused && !state.occluded && state.read_only_focus.is_none() && anchor == focus { + if state.focused && !state.occluded && state.object_focus.is_none() && anchor == focus { let now = Instant::now(); if now >= state.blink_at { state.caret = !state.caret; @@ -1588,6 +1680,11 @@ enum Hit { grab: [f32; 2], }, ReadOnly(usize), + /// `handle` is [0, 0] on the picture and a direction on the selected picture's handles. + Image { + id: onestore::ExGuid, + handle: [i8; 2], + }, } fn page_hit_test( @@ -1696,6 +1793,7 @@ fn page_hit_test( canvas::gpu::page::SceneHit::Outline(hit) => Some(hit), canvas::gpu::page::SceneHit::Date(field) => Some(Hit::Date(field)), canvas::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)), + canvas::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }), } }; [Layer::Grips, Layer::Body, Layer::Below] @@ -1707,7 +1805,7 @@ fn page_primitives<'a>( editor: &'a CanvasEditor, scene: Option<&'a (PageScene, [f32; 2])>, preview: Option>, - read_only_focus: Option, + object_focus: Option, show_caret: bool, scale: f32, display_scale: f32, @@ -1726,7 +1824,7 @@ fn page_primitives<'a>( Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin, _ => outline.origin(), }; - if (read_only_focus.is_none() && outline.id == editor.active_outline().id) + if (object_focus.is_none() && outline.id == editor.active_outline().id) || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id) || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id) { @@ -1738,7 +1836,7 @@ fn page_primitives<'a>( ); } append_outline( - (read_only_focus.is_none() + (object_focus.is_none() && outline.id == editor.active_outline().id && !matches!(preview, Some(PointerFeedback::Resize(_)))) .then_some(editor), @@ -1751,7 +1849,11 @@ fn page_primitives<'a>( ) }; if let Some((scene, origin)) = scene { - scene.append_primitives_with(&mut primitives, *origin, Some(editor), &paint)?; + let moving = match preview { + Some(PointerFeedback::Image(id, origin, size)) => Some((id, image_rect(origin, size))), + _ => None, + }; + scene.append_primitives_with(&mut primitives, *origin, Some(editor), moving, &paint)?; } for outline in editor .visible_outlines() @@ -1759,7 +1861,7 @@ fn page_primitives<'a>( { paint(outline.id, [0.0; 2], &mut primitives)?; } - if read_only_focus.is_none() + if object_focus.is_none() && let Some(outline) = editor.caret_outline() { append_outline( @@ -1772,7 +1874,20 @@ fn page_primitives<'a>( &mut primitives, )?; } - if let Some(index) = read_only_focus { + if let Some(ObjectFocus::Image(id)) = object_focus { + let (origin, size) = match preview { + Some(PointerFeedback::Image(moving, origin, size)) if moving == id => (origin, size), + _ => editor + .image_placement(id) + .ok_or("The selected picture is missing.")?, + }; + append_image_chrome( + image_rect(origin, size), + display_scale / scale, + &mut primitives, + ); + } + if let Some(ObjectFocus::ReadOnly(index)) = object_focus { let (scene, offset) = scene.unwrap(); let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect(); let [x0, y0, x1, y1] = [ @@ -1797,6 +1912,124 @@ fn page_primitives<'a>( Ok(primitives) } +/// Native picture handles sit on a selection border drawn 5 px outside the picture, named +/// by their direction from its center. +fn image_handles(rect: [f32; 4], pixel: f32) -> impl Iterator { + let border = [ + rect[0] - 5.0 * pixel, + rect[1] - 5.0 * pixel, + rect[2] + 5.0 * pixel, + rect[3] + 5.0 * pixel, + ]; + [-1, 0, 1] + .into_iter() + .flat_map(|y| [-1, 0, 1].map(|x| [x, y])) + .filter(|handle| *handle != [0, 0]) + .map(move |handle| { + ( + handle, + std::array::from_fn(|axis| { + let [start, end] = [border[axis], border[axis + 2]]; + start + (end - start) * f32::from(handle[axis] + 1) * 0.5 + }), + ) + }) +} + +fn image_handle_at(rect: [f32; 4], pixel: f32, point: [f32; 2]) -> Option<[i8; 2]> { + image_handles(rect, pixel) + .find(|(_, center)| (0..2).all(|axis| (point[axis] - center[axis]).abs() <= 5.0 * pixel)) + .map(|(handle, _)| handle) +} + +fn handle_cursor(handle: [i8; 2]) -> winit::window::CursorIcon { + use winit::window::CursorIcon; + match handle { + [0, 0] => CursorIcon::Move, + [_, 0] => CursorIcon::EwResize, + [0, _] => CursorIcon::NsResize, + [x, y] if x == y => CursorIcon::NwseResize, + _ => CursorIcon::NeswResize, + } +} + +fn image_rect(origin: [f32; 2], size: [f32; 2]) -> [f32; 4] { + [ + origin[0], + origin[1], + origin[0] + size[0], + origin[1] + size[1], + ] +} + +/// Drags a picture's `handle` by `delta`: edges stretch one axis, corners keep the aspect +/// ratio at the larger of the two scales, and the opposite side stays fixed. +fn resize_image( + origin: [f32; 2], + size: [f32; 2], + handle: [i8; 2], + delta: [f32; 2], +) -> ([f32; 2], [f32; 2]) { + let mut scale: [f32; 2] = std::array::from_fn(|axis| { + (size[axis] + f32::from(handle[axis]) * delta[axis]) / size[axis] + }); + if !handle.contains(&0) { + scale = [scale[0].max(scale[1]); 2]; + } + let resized: [f32; 2] = std::array::from_fn(|axis| (size[axis] * scale[axis]).max(1.0)); + let origin = std::array::from_fn(|axis| { + if handle[axis] < 0 { + origin[axis] + size[axis] - resized[axis] + } else { + origin[axis] + } + }); + (origin, resized) +} + +fn append_image_chrome(rect: [f32; 4], pixel: f32, primitives: &mut Vec>) { + let mut tint = canvas::gpu::colorref(0x00e0d2e6); + // Native pictures take this tint at 25% in sRGB; 10% in linear light matches it. + tint[3] = 0.1; + primitives.push(Primitive::Rect { rect, color: tint }); + primitives.push(Primitive::RoundedRect { + rect: [ + rect[0] - 5.0 * pixel, + rect[1] - 5.0 * pixel, + rect[2] + 5.0 * pixel, + rect[3] + 5.0 * pixel, + ], + radius: [0.0; 2], + stroke: Some(Stroke::Dashed(pixel)), + color: canvas::gpu::colorref(0x00ff9a31), + }); + for (handle, [x, y]) in image_handles(rect, pixel) { + let (half, radius) = if handle.contains(&0) { + (3.5, 0.0) + } else { + (4.0, 4.0) + }; + let rect = [ + x - half * pixel, + y - half * pixel, + x + half * pixel, + y + half * pixel, + ]; + primitives.push(Primitive::RoundedRect { + rect, + radius: [radius * pixel; 2], + stroke: None, + color: canvas::gpu::colorref(0x00ffefe7), + }); + primitives.push(Primitive::RoundedRect { + rect, + radius: [radius * pixel; 2], + stroke: Some(Stroke::Solid(pixel)), + color: canvas::gpu::colorref(0x00dea67b), + }); + } +} + fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) -> Selection { if unit == SelectionUnit::Grapheme { return Selection { @@ -2500,6 +2733,33 @@ mod tests { ); } + #[test] + fn picture_handles_resize_as_onenote_does() { + // Native drags of the 333 x 200.1 pt mockup on "av: casual night in the trees". + let (origin, size) = ([468.0, 86.4], [333.0, 200.1]); + let (corner_origin, corner) = resize_image(origin, size, [1, -1], [-75.0, 30.75]); + assert!((corner[0] - 281.827).abs() < 0.01 && (corner[1] - 169.351).abs() < 0.01); + assert!((corner_origin[1] - 117.15).abs() < 0.01 && corner_origin[0] == 468.0); + assert_eq!( + resize_image(origin, size, [1, 0], [46.5, 10.0]), + (origin, [379.5, 200.1]) + ); + let (left_origin, left) = resize_image(origin, size, [-1, 0], [400.0, 0.0]); + assert_eq!(left, [1.0, 200.1]); + assert_eq!(left_origin[0], 468.0 + 333.0 - 1.0); + let pixel = 0.75; + let rect = image_rect(origin, size); + assert_eq!( + image_handle_at(rect, pixel, [468.0 - 3.75, 86.4 - 3.75]), + Some([-1, -1]) + ); + assert_eq!( + image_handle_at(rect, pixel, [468.0 + 166.5, 286.5 + 3.75]), + Some([0, 1]) + ); + assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0]), None); + } + #[test] fn grouped_drag_keeps_the_initial_word_when_reversing_direction() { let selection = |start, end| { @@ -3257,7 +3517,16 @@ mod tests { .any(|(_, color)| *color == macos::text_colors()[0]) ); let selected = rectangles( - page_primitives(&editor, Some(&scene), None, Some(0), true, scale, 1.0).unwrap(), + page_primitives( + &editor, + Some(&scene), + None, + Some(ObjectFocus::ReadOnly(0)), + true, + scale, + 1.0, + ) + .unwrap(), ); assert!( !selected @@ -3267,8 +3536,16 @@ mod tests { assert_eq!( selected, rectangles( - page_primitives(&editor, Some(&scene), None, Some(0), false, scale, 1.0) - .unwrap() + page_primitives( + &editor, + Some(&scene), + None, + Some(ObjectFocus::ReadOnly(0)), + false, + scale, + 1.0 + ) + .unwrap() ) ); assert_eq!( @@ -3285,7 +3562,16 @@ mod tests { .any(|(_, color)| *color == macos::text_colors()[1]) ); let selected = rectangles( - page_primitives(&editor, Some(&scene), None, Some(0), true, 1.0, 1.0).unwrap(), + page_primitives( + &editor, + Some(&scene), + None, + Some(ObjectFocus::ReadOnly(0)), + true, + 1.0, + 1.0, + ) + .unwrap(), ); assert!( !selected diff --git a/tools/canvas/README.md b/tools/canvas/README.md index fe68ca40db0e8a4ba42d5bd94671c892c2c43c8c..cc4e0456eb8b6f3e5734d8abb27e7568b57ab139 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -115,7 +115,7 @@ The host sleeps its caret timer while macOS reports the window occluded and requ "target/Snowbound.app/Contents/MacOS/Snowbound" --page PRIVATE_SECTION_COPY EXACT_PAGE_TITLE --substitute-font CARLITO_REGULAR_FILE ``` -`--page` opens supported body outlines and title text as temporary editable objects in their source positions. It accepts a section copy and exact page title, without the separate text-file/width arguments. Title text supports editing, wrapping, IME and undo, and remains present when empty. Date text follows its height; clicking the date or time opens the macOS date control and commits into the same undo history. Image editing is not exposed. Character editing, selection, IME and undo use the same outline model as new pages. Existing structured or tagged paragraphs accept in-paragraph edits; their structural split/join remains restricted. Newly created flat outlines support the full structural workflow. The app never writes the section file. +`--page` opens supported body outlines and title text as temporary editable objects in their source positions. It accepts a section copy and exact page title, without the separate text-file/width arguments. Title text supports editing, wrapping, IME and undo, and remains present when empty. Date text follows its height; clicking the date or time opens the macOS date control and commits into the same undo history. Clicking a picture selects it with OneNote's dashed border, tint and eight handles. Dragging it moves it on the page grid (Option places it freely); edge handles stretch one axis and corner handles keep the aspect ratio, anchored at the opposite side. Background pictures pass clicks through. Moves and resizes share the undo history. Character editing, selection, IME and undo use the same outline model as new pages. Existing structured or tagged paragraphs accept in-paragraph edits; their structural split/join remains restricted. Newly created flat outlines support the full structural workflow. The app never writes the section file. `PageScene::from_page` constructs the editor-owned page and decodes its image assets. Imported paint order stays with the editor; each text slot invokes the host’s outline painter. Movement reads the current model geometry without decoding images again. New outlines draw after the imported page. The close guard compares all object layouts, editable text and the creation timestamp with the opening state, including displacement that remains after a title is shortened. @@ -129,7 +129,7 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA Unsupported top-level objects, outlines or titles render as read-only placeholders in source order. Missing image payloads or dimensions produce an image-unavailable placeholder. The editor owns the imported source values for those objects; the importer’s `Unsupported` record contains identity, class and layout metadata, not an opaque copy of unparsed notebook bytes. Supported body outlines still enter the editor. If none exist, a provisional caret appears to the right of the placeholders; typing creates an annotation outline. -Placeholders use source positions and at least 160 pt width, expanding their height to contain the status text. This is an explicit substitute for unavailable rendering, not a geometric reconstruction. Pointer presses focus placeholders, hide the previous text caret/selection and retire its IME composition. Typing, deletion, paste, text-selection commands, undo/redo and outline movement are suppressed while a placeholder has focus. Escape restores the previous text focus; Ctrl-Tab cycles through text outlines and placeholders. Zoom remains available, and Cmd-Shift-N creates an editable annotation beside the focused object. AccessKit exposes placeholder focus, status and bounds without text-edit actions; identities persist across editor updates and viewport changes. Focus reveals offscreen placeholders and suspends caret-blink scheduling. Hit-testing follows paint order for overlapping editable outlines and placeholders: new annotations draw last and receive hits first, while imported objects resolve from front to back. Each body outline’s header, width grips and text participate at its own position in that order before any outline’s padding or tag gutters, so a width handle stays reachable under the left padding of the outline above it. Supported titles expose editable text without move/resize handles; decoded images have no editable hit target. Invalid geometry, malformed image data and resource-budget failures remain errors. +Placeholders use source positions and at least 160 pt width, expanding their height to contain the status text. This is an explicit substitute for unavailable rendering, not a geometric reconstruction. Pointer presses focus placeholders, hide the previous text caret/selection and retire its IME composition. Typing, deletion, paste, text-selection commands, undo/redo and outline movement are suppressed while a placeholder has focus. Escape restores the previous text focus; Ctrl-Tab cycles through text outlines and placeholders. Zoom remains available, and Cmd-Shift-N creates an editable annotation beside the focused object. AccessKit exposes placeholder focus, status and bounds without text-edit actions; identities persist across editor updates and viewport changes. Focus reveals offscreen placeholders and suspends caret-blink scheduling. Hit-testing follows paint order for overlapping editable outlines and placeholders: new annotations draw last and receive hits first, while imported objects resolve from front to back. Each body outline’s header, width grips and text participate at its own position in that order before any outline’s padding or tag gutters, so a width handle stays reachable under the left padding of the outline above it. Supported titles expose editable text without move/resize handles. Pictures other than backgrounds take hits at their paint position, and a selected picture's handles take hits above everything else. Invalid geometry, malformed image data and resource-budget failures remain errors. Document and layout values are read-only outside the editor; edits update their retained geometry together. `CanvasEditor::from_outlines` remains available for text-only construction. -- 2.54.0