authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-24 01:26:06-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:21-07:00
logb5d8955327de829054dd337a86eed6fc6582be62
treec8f61922efc1a5865a75017ae88c4cadf1739008
parent72cf5607adf5f82934a103defa655f845b225b4a
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

4 files changed, 612 insertions(+), 92 deletions(-)

crates/canvas/src/editor.rs+146
......@@ -620,6 +620,10 @@ enum History {
620620 outline: ExGuid,
621621 layout: onestore::document::Layout,
622622 },
623 Image {
624 image: ExGuid,
625 layout: onestore::document::Layout,
626 },
623627 Remove {
624628 outline: onestore::ExGuid,
625629 focus: RestoreFocus,
......@@ -1271,6 +1275,62 @@ impl CanvasEditor {
12711275 Ok(())
12721276 }
12731277
1278 /// Origin and size of a picture the user can select; OneNote passes clicks through
1279 /// backgrounds.
1280 pub fn image_placement(&self, id: ExGuid) -> Option<([f32; 2], [f32; 2])> {
1281 let layout = &self.image(id)?.layout;
1282 Some((
1283 [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)],
1284 [layout.max_width?, layout.max_height?],
1285 ))
1286 }
1287
1288 fn image(&self, id: ExGuid) -> Option<&onestore::page::Image> {
1289 self.objects.iter().find_map(|object| match object {
1290 page::Content::Image(image) if image.id == id && !image.background => Some(image),
1291 _ => None,
1292 })
1293 }
1294
1295 fn image_mut(&mut self, id: ExGuid) -> Option<&mut onestore::page::Image> {
1296 self.objects.iter_mut().find_map(|object| match object {
1297 page::Content::Image(image) if image.id == id && !image.background => Some(image),
1298 _ => None,
1299 })
1300 }
1301
1302 /// Moves a picture, and resizes it when `size` differs from the stored size.
1303 pub fn place_image(
1304 &mut self,
1305 id: ExGuid,
1306 origin: [f32; 2],
1307 size: [f32; 2],
1308 ) -> Result<(), EditError> {
1309 if !origin.iter().chain(&size).all(|value| value.is_finite())
1310 || size.iter().any(|value| *value <= 0.0)
1311 {
1312 return Err(EditError::InvalidRange);
1313 }
1314 let image = self.image(id).ok_or(EditError::InvalidRange)?;
1315 let mut layout = image.layout.clone();
1316 [layout.x, layout.y] = origin.map(Some);
1317 if [layout.max_width, layout.max_height] != size.map(Some) {
1318 [layout.max_width, layout.max_height] = size.map(Some);
1319 layout.width_set_by_user = Some(true);
1320 }
1321 if layout == image.layout {
1322 return Ok(());
1323 }
1324 self.finish_composition();
1325 let previous = std::mem::replace(&mut self.image_mut(id).unwrap().layout, layout);
1326 self.undo.push(History::Image {
1327 image: id,
1328 layout: previous,
1329 });
1330 self.redo.clear();
1331 Ok(())
1332 }
1333
12741334 pub fn selection(&self) -> Selection {
12751335 self.active_outline().selection
12761336 }
......@@ -2249,6 +2309,7 @@ impl CanvasEditor {
22492309 | History::Layout { outline, .. } => {
22502310 self.outlines.iter().any(|item| item.id == *outline)
22512311 }
2312 History::Image { image, .. } => self.image(*image).is_some(),
22522313 History::Remove { outline, focus } => {
22532314 self.outlines.iter().any(|item| item.id == *outline)
22542315 && match focus {
......@@ -2396,6 +2457,10 @@ impl CanvasEditor {
23962457 layout: previous,
23972458 }
23982459 }
2460 History::Image { image, layout } => History::Image {
2461 image,
2462 layout: std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout),
2463 },
23992464 History::Remove { outline, focus } => {
24002465 let index = self
24012466 .outlines
......@@ -4330,6 +4395,87 @@ mod tests {
43304395 }
43314396 }
43324397
4398 #[test]
4399 fn pictures_move_and_resize_through_history_and_backgrounds_stay_put() {
4400 use onestore::page::{Image, Page, PageObject};
4401 let mut engine = TextEngine::default();
4402 let [picture, background] = [false, true].map(|background| Image {
4403 size: None,
4404 id: onestore::page::text::new_id().unwrap(),
4405 layout: onestore::document::Layout {
4406 x: Some(468.75),
4407 y: Some(86.4),
4408 max_width: Some(333.0),
4409 max_height: Some(200.1),
4410 ..Default::default()
4411 },
4412 bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())),
4413 alt: None,
4414 background,
4415 });
4416 let (id, background_id) = (picture.id, background.id);
4417 let mut editor = CanvasEditor::from_page(
4418 Page {
4419 title: String::new(),
4420 identity: None,
4421 created: None,
4422 margin_origin: [36.0, 14.4],
4423 definitions: BTreeMap::new(),
4424 objects: vec![PageObject::Image(background), PageObject::Image(picture)],
4425 },
4426 &mut engine,
4427 )
4428 .unwrap();
4429 let stored = |editor: &CanvasEditor| {
4430 editor
4431 .page()
4432 .unwrap()
4433 .objects
4434 .into_iter()
4435 .find_map(|object| match object {
4436 PageObject::Image(image) if image.id == id => Some(image.layout),
4437 _ => None,
4438 })
4439 .unwrap()
4440 };
4441 let original = stored(&editor);
4442 assert_eq!(
4443 editor.image_placement(id),
4444 Some(([468.75, 86.4], [333.0, 200.1]))
4445 );
4446 assert!(editor.image_placement(background_id).is_none());
4447 assert_eq!(
4448 editor.place_image(background_id, [0.0; 2], [10.0; 2]),
4449 Err(EditError::InvalidRange)
4450 );
4451 assert_eq!(
4452 editor.place_image(id, [0.0; 2], [0.0, 10.0]),
4453 Err(EditError::InvalidRange)
4454 );
4455 editor
4456 .place_image(id, [513.75, 104.4], [333.0, 200.1])
4457 .unwrap();
4458 let moved = stored(&editor);
4459 assert_eq!([moved.x, moved.y], [Some(513.75), Some(104.4)]);
4460 assert_eq!(moved.width_set_by_user, None);
4461 editor
4462 .place_image(id, [513.75, 104.4], [281.8, 169.4])
4463 .unwrap();
4464 let resized = stored(&editor);
4465 assert_eq!(
4466 [resized.max_width, resized.max_height],
4467 [Some(281.8), Some(169.4)]
4468 );
4469 assert_eq!(resized.width_set_by_user, Some(true));
4470 assert!(editor.undo(&mut engine).unwrap());
4471 assert_eq!(stored(&editor), moved);
4472 assert!(editor.undo(&mut engine).unwrap());
4473 assert_eq!(stored(&editor), original);
4474 assert!(editor.redo(&mut engine).unwrap());
4475 assert!(editor.redo(&mut engine).unwrap());
4476 assert_eq!(stored(&editor), resized);
4477 }
4478
43334479 #[test]
43344480 fn title_flow_moves_page_objects_atomically_and_cancels_composition() {
43354481 use onestore::page::{Image, Page, PageObject, Title};
crates/canvas/src/gpu/page.rs+97-9
......@@ -20,6 +20,7 @@ pub enum SceneHit<T> {
2020 Outline(T),
2121 Date(DateField),
2222 ReadOnly(usize),
23 Image(onestore::ExGuid),
2324}
2425
2526fn outline_origin(
......@@ -283,6 +284,19 @@ impl PageScene {
283284 return Some(SceneHit::ReadOnly(readonly));
284285 }
285286 }
287 Content::Image(source) if !source.background => {
288 let [x, y] = [
289 source.layout.x.unwrap_or(0.0),
290 source.layout.y.unwrap_or(0.0),
291 ];
292 if let (Some(width), Some(height)) =
293 (source.layout.max_width, source.layout.max_height)
294 && (x..=x + width).contains(&point[0])
295 && (y..=y + height).contains(&point[1])
296 {
297 return Some(SceneHit::Image(source.id));
298 }
299 }
286300 Content::Outline { .. } | Content::Image(_) => {}
287301 }
288302 }
......@@ -295,16 +309,18 @@ impl PageScene {
295309 primitives: &mut Vec<Primitive<'a>>,
296310 offset: [f32; 2],
297311 ) -> Result<(), SceneError> {
298 self.append_primitives_with(primitives, offset, None, |_, _, _| {
312 self.append_primitives_with(primitives, offset, None, None, |_, _, _| {
299313 Err(SceneError::MissingOutline)
300314 })
301315 }
302316
317 /// `moving` draws one picture at a previewed rectangle instead of its stored layout.
303318 pub fn append_primitives_with<'a, E: From<SceneError>>(
304319 &'a self,
305320 primitives: &mut Vec<Primitive<'a>>,
306321 offset: [f32; 2],
307322 editor: Option<&'a CanvasEditor>,
323 moving: Option<(onestore::ExGuid, [f32; 4])>,
308324 mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec<Primitive<'a>>) -> Result<(), E>,
309325 ) -> Result<(), E> {
310326 for content in self.objects(editor)? {
......@@ -373,14 +389,22 @@ impl PageScene {
373389 .images
374390 .get(&source.id)
375391 .ok_or(SceneError::MissingImage)?,
376 rect: [
377 object_origin[0],
378 object_origin[1],
379 object_origin[0]
380 + source.layout.max_width.ok_or(SceneError::MissingImage)?,
381 object_origin[1]
382 + source.layout.max_height.ok_or(SceneError::MissingImage)?,
383 ],
392 rect: match moving {
393 Some((id, [x0, y0, x1, y1])) if id == source.id => [
394 x0 + offset[0],
395 y0 + offset[1],
396 x1 + offset[0],
397 y1 + offset[1],
398 ],
399 _ => [
400 object_origin[0],
401 object_origin[1],
402 object_origin[0]
403 + source.layout.max_width.ok_or(SceneError::MissingImage)?,
404 object_origin[1]
405 + source.layout.max_height.ok_or(SceneError::MissingImage)?,
406 ],
407 },
384408 }),
385409 Content::Outline { .. } | Content::Date { .. } => {
386410 let outline = match content {
......@@ -930,6 +954,7 @@ mod tests {
930954 &mut primitives,
931955 [0.0; 2],
932956 Some(editor),
957 None,
933958 |_, _, _| Ok(()),
934959 )
935960 .unwrap();
......@@ -1327,6 +1352,7 @@ mod tests {
13271352 &mut primitives,
13281353 [0.0; 2],
13291354 Some(editor),
1355 None,
13301356 |id, offset, primitives| {
13311357 let outline = editor
13321358 .outlines()
......@@ -1561,4 +1587,66 @@ mod tests {
15611587 assert_eq!(first.pixels.id(), second.pixels.id());
15621588 assert_eq!(first.pixels.as_ref(), [255, 0, 0, 255]);
15631589 }
1590
1591 #[test]
1592 fn foreground_pictures_take_hits_and_draw_at_their_moving_preview() {
1593 let mut bytes = Vec::new();
1594 {
1595 let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
1596 encoder.set_color(png::ColorType::Rgba);
1597 encoder.set_depth(png::BitDepth::Eight);
1598 encoder
1599 .write_header()
1600 .unwrap()
1601 .write_image_data(&[17, 34, 51, 255])
1602 .unwrap();
1603 }
1604 let image = |x: f32, background| Image {
1605 size: None,
1606 id: onestore::page::text::new_id().unwrap(),
1607 layout: Layout {
1608 x: Some(x),
1609 y: Some(0.0),
1610 max_width: Some(100.0),
1611 max_height: Some(100.0),
1612 ..Default::default()
1613 },
1614 bytes: Some(Arc::from(bytes.clone())),
1615 alt: None,
1616 background,
1617 };
1618 let [background, picture] = [image(0.0, true), image(50.0, false)];
1619 let id = picture.id;
1620 let page = Page {
1621 identity: None,
1622 created: None,
1623 title: String::new(),
1624 margin_origin: [0.0; 2],
1625 definitions: BTreeMap::new(),
1626 objects: vec![PageObject::Image(background), PageObject::Image(picture)],
1627 };
1628 let mut engine = TextEngine::default();
1629 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
1630 let hit = |x| scene.hit_test::<()>([x, 10.0], Some(&editor), |_| None);
1631 assert_eq!(hit(60.0), Some(SceneHit::Image(id)));
1632 assert_eq!(hit(10.0), None);
1633 let mut primitives = Vec::new();
1634 scene
1635 .append_primitives_with::<SceneError>(
1636 &mut primitives,
1637 [0.0; 2],
1638 Some(&editor),
1639 Some((id, [70.0, 5.0, 120.0, 55.0])),
1640 |_, _, _| Ok(()),
1641 )
1642 .unwrap();
1643 let rects: Vec<_> = primitives
1644 .iter()
1645 .filter_map(|primitive| match primitive {
1646 Primitive::Image { rect, .. } => Some(*rect),
1647 _ => None,
1648 })
1649 .collect();
1650 assert_eq!(rects, [[0.0, 0.0, 100.0, 100.0], [70.0, 5.0, 120.0, 55.0]]);
1651 }
15641652}
crates/snowbound/src/main.rs+367-81
......@@ -123,6 +123,13 @@ enum Drag {
123123 grab: [f32; 2],
124124 pending_press: Option<[f32; 2]>,
125125 },
126 Image {
127 id: onestore::ExGuid,
128 handle: [i8; 2],
129 /// Document point of the press.
130 press: [f32; 2],
131 pending_press: Option<[f32; 2]>,
132 },
126133}
127134
128135#[derive(Clone, Copy)]
......@@ -130,6 +137,24 @@ enum PointerFeedback<'a> {
130137 Hover(onestore::ExGuid),
131138 Move(onestore::ExGuid, [f32; 2]),
132139 Resize(&'a TextOutline),
140 /// A picture being moved or resized, drawn at this origin and size.
141 Image(onestore::ExGuid, [f32; 2], [f32; 2]),
142}
143
144/// A non-text object holding focus, which hides the text caret and suspends typing.
145#[derive(Clone, Copy, Debug, PartialEq)]
146enum ObjectFocus {
147 ReadOnly(usize),
148 Image(onestore::ExGuid),
149}
150
151impl ObjectFocus {
152 fn read_only(self) -> Option<usize> {
153 match self {
154 Self::ReadOnly(index) => Some(index),
155 Self::Image(_) => None,
156 }
157 }
133158}
134159
135160struct State {
......@@ -151,7 +176,7 @@ struct State {
151176 pointer_inside: bool,
152177 last_click: Option<(Instant, [f32; 2], u8)>,
153178 drag: Option<Drag>,
154 read_only_focus: Option<usize>,
179 object_focus: Option<ObjectFocus>,
155180 modifiers: ModifiersState,
156181 focused: bool,
157182 occluded: bool,
......@@ -310,7 +335,7 @@ impl State {
310335 pointer_inside: false,
311336 last_click: None,
312337 drag: None,
313 read_only_focus: None,
338 object_focus: None,
314339 modifiers: ModifiersState::empty(),
315340 focused: true,
316341 occluded: false,
......@@ -352,15 +377,75 @@ impl State {
352377 ))
353378 }
354379
355 fn set_read_only_focus(&mut self, index: Option<usize>) {
356 if self.read_only_focus != index {
380 fn image_preview(&self) -> Option<(onestore::ExGuid, [f32; 2], [f32; 2])> {
381 let Drag::Image {
382 id,
383 handle,
384 press,
385 pending_press: None,
386 } = self.drag.as_ref()?
387 else {
388 return None;
389 };
390 let (origin, size) = self.editor.image_placement(*id)?;
391 let point = self.viewport.document_point(self.pointer);
392 let delta = [point[0] - press[0], point[1] - press[1]];
393 if *handle != [0, 0] {
394 let (origin, size) = resize_image(origin, size, *handle, delta);
395 return Some((*id, origin, size));
396 }
397 let origin = [origin[0] + delta[0], origin[1] + delta[1]];
398 let origin = if self.modifiers.alt_key() {
399 origin
400 } else {
401 snap_to_grid(origin)
402 };
403 Some((*id, origin, size))
404 }
405
406 /// The selected picture's handles lie above the page; its body keeps its paint order.
407 fn hit_test(&self, point: [f32; 2]) -> Option<Hit> {
408 let pixel = self.display_scale / self.viewport.scale;
409 if let Some(ObjectFocus::Image(id)) = self.object_focus
410 && let Some((origin, size)) = self.editor.image_placement(id)
411 && let Some(handle) = image_handle_at(image_rect(origin, size), pixel, point)
412 {
413 return Some(Hit::Image { id, handle });
414 }
415 page_hit_test(&self.editor, self.scene.as_ref(), point, pixel)
416 }
417
418 fn set_object_focus(&mut self, focus: Option<ObjectFocus>) {
419 if self.object_focus != focus {
357420 self.editor.finish_composition();
358421 self.drag = None;
359 self.read_only_focus = index;
360 self.window.set_ime_allowed(index.is_none());
422 self.object_focus = focus;
423 self.window.set_ime_allowed(focus.is_none());
361424 }
362425 }
363426
427 /// Page coordinates of a focused object.
428 fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] {
429 let (scene, offset) = self.scene.as_ref().unwrap();
430 let [x0, y0, x1, y1] = match focus {
431 ObjectFocus::ReadOnly(index) => scene
432 .read_only(Some(&self.editor))
433 .nth(index)
434 .unwrap()
435 .rect(),
436 ObjectFocus::Image(id) => {
437 let (origin, size) = self.editor.image_placement(id).unwrap();
438 image_rect(origin, size)
439 }
440 };
441 [
442 x0 + offset[0],
443 y0 + offset[1],
444 x1 + offset[0],
445 y1 + offset[1],
446 ]
447 }
448
364449 fn changed(&mut self) -> Result<(), Box<dyn Error>> {
365450 self.scroll().clamp(&mut self.viewport);
366451 self.caret = true;
......@@ -446,7 +531,7 @@ impl State {
446531 self.editor = editor;
447532 self.scene = Some((scene, [0.0; 2]));
448533 self.drag = None;
449 self.read_only_focus = None;
534 self.object_focus = None;
450535 self.window.set_title(&session.window_title());
451536 self.update_accessibility()?;
452537 self.window.request_redraw();
......@@ -535,7 +620,7 @@ impl State {
535620 self.scene.as_ref(),
536621 &self.editor,
537622 self.viewport,
538 self.read_only_focus,
623 self.object_focus.and_then(ObjectFocus::read_only),
539624 );
540625 update
541626 }
......@@ -573,7 +658,7 @@ impl State {
573658 }
574659 if let Some(index) = self.accessibility.read_only_for_node(request.target_node) {
575660 if request.action == Action::Focus {
576 self.set_read_only_focus(Some(index));
661 self.set_object_focus(Some(ObjectFocus::ReadOnly(index)));
577662 self.window.focus_window();
578663 self.reveal_focus()?;
579664 self.changed()?;
......@@ -605,7 +690,7 @@ impl State {
605690 }
606691 _ => return Ok(()),
607692 }
608 self.set_read_only_focus(None);
693 self.set_object_focus(None);
609694 self.reveal_focus()?;
610695 self.changed()
611696 }
......@@ -614,19 +699,9 @@ impl State {
614699 if self.viewport.size.contains(&0) {
615700 return Ok(());
616701 }
617 let rect = if let Some(index) = self.read_only_focus {
618 let (scene, offset) = self.scene.as_ref().unwrap();
619 let [x0, y0, x1, y1] = scene
620 .read_only(Some(&self.editor))
621 .nth(index)
622 .unwrap()
623 .rect();
624 parley::BoundingBox {
625 x0: f64::from(x0 + offset[0]),
626 y0: f64::from(y0 + offset[1]),
627 x1: f64::from(x1 + offset[0]),
628 y1: f64::from(y1 + offset[1]),
629 }
702 let rect = if let Some(focus) = self.object_focus {
703 let [x0, y0, x1, y1] = self.object_rect(focus).map(f64::from);
704 parley::BoundingBox { x0, y0, x1, y1 }
630705 } else {
631706 let mut rect = self.editor.caret(1.0)?;
632707 let origin = self.editor.active_outline().origin();
......@@ -647,7 +722,7 @@ impl State {
647722 [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis];
648723 let outline_start = outline_start.min(start);
649724 let outline_end = outline_end.max(end);
650 if self.read_only_focus.is_none()
725 if self.object_focus.is_none()
651726 && (outline_end - outline_start) * f64::from(self.viewport.scale)
652727 <= size - margin * 2.0
653728 {
......@@ -759,6 +834,10 @@ impl State {
759834 _ => self
760835 .preview()
761836 .map(|(id, origin)| PointerFeedback::Move(id, origin))
837 .or_else(|| {
838 self.image_preview()
839 .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size))
840 })
762841 .or_else(|| {
763842 if !self.pointer_inside {
764843 return None;
......@@ -778,10 +857,13 @@ impl State {
778857 }
779858 }),
780859 },
781 self.read_only_focus,
860 self.object_focus,
782861 self.caret
783862 && self.focused
784 && !matches!(self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. })),
863 && !matches!(
864 self.drag,
865 Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. })
866 ),
785867 self.viewport.scale,
786868 self.display_scale,
787869 )?;
......@@ -807,12 +889,15 @@ impl State {
807889 let shift = self.modifiers.shift_key();
808890 let command = self.modifiers.super_key();
809891 let option = self.modifiers.alt_key();
810 if self.read_only_focus.is_some() {
811 if !read_only_shortcut(key, self.modifiers) {
892 if let Some(focus) = self.object_focus {
893 let undo = matches!(focus, ObjectFocus::Image(_))
894 && command
895 && matches!(key, Key::Character(value) if value.eq_ignore_ascii_case("z"));
896 if !undo && !read_only_shortcut(key, self.modifiers) {
812897 return Ok(());
813898 }
814899 if key == &Key::Named(NamedKey::Escape) {
815 self.set_read_only_focus(None);
900 self.set_object_focus(None);
816901 self.reveal_focus()?;
817902 return self.changed();
818903 }
......@@ -832,7 +917,10 @@ impl State {
832917 _ => {}
833918 }
834919 }
835 if matches!(self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. })) {
920 if matches!(
921 self.drag,
922 Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. })
923 ) {
836924 if matches!(
837925 key,
838926 Key::Named(
......@@ -881,14 +969,17 @@ impl State {
881969 if count == 0 {
882970 return self.changed();
883971 }
884 let index = self.read_only_focus.map_or_else(
885 || {
886 outlines
887 .iter()
888 .position(|outline| outline.id == self.editor.active_outline().id)
889 },
890 |index| Some(outlines.len() + index),
891 );
972 let index = self
973 .object_focus
974 .and_then(ObjectFocus::read_only)
975 .map_or_else(
976 || {
977 outlines
978 .iter()
979 .position(|outline| outline.id == self.editor.active_outline().id)
980 },
981 |index| Some(outlines.len() + index),
982 );
892983 let next = index.map_or(if shift { count - 1 } else { 0 }, |index| {
893984 if shift {
894985 (index + count - 1) % count
......@@ -898,9 +989,9 @@ impl State {
898989 });
899990 if next < outlines.len() {
900991 self.editor.focus_outline(outlines[next].id)?;
901 self.set_read_only_focus(None);
992 self.set_object_focus(None);
902993 } else {
903 self.set_read_only_focus(Some(next - outlines.len()));
994 self.set_object_focus(Some(ObjectFocus::ReadOnly(next - outlines.len())));
904995 }
905996 self.reveal_focus()?;
906997 return self.changed();
......@@ -908,14 +999,9 @@ impl State {
908999 if command && let Key::Character(key) = key {
9091000 match key.to_lowercase().as_str() {
9101001 "n" if shift => {
911 let position = if let Some(index) = self.read_only_focus {
912 let (scene, offset) = self.scene.as_ref().unwrap();
913 let rect = scene
914 .read_only(Some(&self.editor))
915 .nth(index)
916 .unwrap()
917 .rect();
918 [rect[2] + offset[0] + 24.0, rect[1] + offset[1]]
1002 let position = if let Some(focus) = self.object_focus {
1003 let rect = self.object_rect(focus);
1004 [rect[2] + 24.0, rect[1]]
9191005 } else {
9201006 let bounds = self.editor.active_outline().bounds();
9211007 [bounds.x1 as f32 + 24.0, bounds.y0 as f32]
......@@ -925,7 +1011,7 @@ impl State {
9251011 snap_to_grid(position),
9261012 DEFAULT_OUTLINE_WIDTH,
9271013 )?;
928 self.set_read_only_focus(None);
1014 self.set_object_focus(None);
9291015 }
9301016 "a" => self.editor.select_all()?,
9311017 "z" => {
......@@ -1137,7 +1223,7 @@ impl ApplicationHandler<UserEvent> for App {
11371223 let event = match event {
11381224 UserEvent::InsertText(text) => {
11391225 if let Some(state) = &mut self.state
1140 && state.read_only_focus.is_none()
1226 && state.object_focus.is_none()
11411227 && !text.is_empty()
11421228 && !text.chars().any(char::is_control)
11431229 {
......@@ -1282,12 +1368,7 @@ impl ApplicationHandler<UserEvent> for App {
12821368 WindowEvent::CursorMoved { position, .. } => {
12831369 state.pointer_inside = true;
12841370 state.pointer = [position.x as f32, position.y as f32];
1285 let hit = page_hit_test(
1286 &state.editor,
1287 state.scene.as_ref(),
1288 state.viewport.document_point(state.pointer),
1289 state.display_scale / state.viewport.scale,
1290 );
1371 let hit = state.hit_test(state.viewport.document_point(state.pointer));
12911372 let scrollbar = state
12921373 .scroll()
12931374 .hit_test(state.viewport, state.display_scale, state.pointer)
......@@ -1295,6 +1376,8 @@ impl ApplicationHandler<UserEvent> for App {
12951376 state.window.set_cursor(match (&state.drag, hit) {
12961377 (Some(Drag::Scrollbar { .. }), _) => winit::window::CursorIcon::Default,
12971378 (None, _) if scrollbar => winit::window::CursorIcon::Default,
1379 (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle),
1380 (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle),
12981381 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => {
12991382 winit::window::CursorIcon::EwResize
13001383 }
......@@ -1330,7 +1413,9 @@ impl ApplicationHandler<UserEvent> for App {
13301413 .select(drag_selection(*anchor, target, *unit))?;
13311414 state.changed()?;
13321415 }
1333 Some(Drag::Outline { pending_press, .. }) => {
1416 Some(
1417 Drag::Outline { pending_press, .. } | Drag::Image { pending_press, .. },
1418 ) => {
13341419 if pending_press.is_some_and(|press| {
13351420 (0..2).any(|axis| {
13361421 (state.pointer[axis] - press[axis]).abs()
......@@ -1387,18 +1472,22 @@ impl ApplicationHandler<UserEvent> for App {
13871472 if scrollbar.is_some() {
13881473 state.drag = scrollbar;
13891474 } else {
1390 match page_hit_test(
1391 &state.editor,
1392 state.scene.as_ref(),
1393 point,
1394 state.display_scale / state.viewport.scale,
1395 ) {
1475 match state.hit_test(point) {
13961476 Some(Hit::Date(field)) => state.edit_date(field)?,
13971477 Some(Hit::ReadOnly(index)) => {
1398 state.set_read_only_focus(Some(index))
1478 state.set_object_focus(Some(ObjectFocus::ReadOnly(index)))
1479 }
1480 Some(Hit::Image { id, handle }) => {
1481 state.set_object_focus(Some(ObjectFocus::Image(id)));
1482 state.drag = Some(Drag::Image {
1483 id,
1484 handle,
1485 press: point,
1486 pending_press: Some(state.pointer),
1487 });
13991488 }
14001489 Some(Hit::Handle { id, grab }) => {
1401 state.set_read_only_focus(None);
1490 state.set_object_focus(None);
14021491 state.editor.focus_outline(id)?;
14031492 state.drag = Some(Drag::Outline {
14041493 id,
......@@ -1407,7 +1496,7 @@ impl ApplicationHandler<UserEvent> for App {
14071496 });
14081497 }
14091498 Some(Hit::Resize { id, grab }) => {
1410 state.set_read_only_focus(None);
1499 state.set_object_focus(None);
14111500 state.editor.focus_outline(id)?;
14121501 state.drag = Some(Drag::Resize {
14131502 outline: None,
......@@ -1416,9 +1505,9 @@ impl ApplicationHandler<UserEvent> for App {
14161505 }
14171506 Some(Hit::Text { id, point }) => {
14181507 let extend = state.modifiers.shift_key()
1419 && state.read_only_focus.is_none()
1508 && state.object_focus.is_none()
14201509 && id == state.editor.active_outline().id;
1421 state.set_read_only_focus(None);
1510 state.set_object_focus(None);
14221511 state.editor.focus_outline(id)?;
14231512 let previous = state.editor.selection();
14241513 state.editor.select_below(&mut state.engine, id, point)?;
......@@ -1464,7 +1553,7 @@ impl ApplicationHandler<UserEvent> for App {
14641553 position,
14651554 DEFAULT_OUTLINE_WIDTH,
14661555 )?;
1467 state.set_read_only_focus(None);
1556 state.set_object_focus(None);
14681557 state.drag = Some(Drag::Text {
14691558 anchor: state.editor.selection(),
14701559 unit: SelectionUnit::Grapheme,
......@@ -1474,6 +1563,9 @@ impl ApplicationHandler<UserEvent> for App {
14741563 }
14751564 } else {
14761565 let preview = state.preview();
1566 if let Some((id, origin, size)) = state.image_preview() {
1567 state.editor.place_image(id, origin, size)?;
1568 }
14771569 if let Some(Drag::Resize {
14781570 outline: Some(outline),
14791571 ..
......@@ -1508,7 +1600,7 @@ impl ApplicationHandler<UserEvent> for App {
15081600 {
15091601 state.key(&event.logical_key, event.text.as_deref())?;
15101602 }
1511 WindowEvent::Ime(_) if state.read_only_focus.is_some() => {}
1603 WindowEvent::Ime(_) if state.object_focus.is_some() => {}
15121604 WindowEvent::Ime(Ime::Preedit(text, cursor)) => {
15131605 if text.is_empty() {
15141606 state.editor.cancel_composition(&mut state.engine)?;
......@@ -1558,7 +1650,7 @@ impl ApplicationHandler<UserEvent> for App {
15581650 return;
15591651 };
15601652 let [anchor, focus] = state.editor.selection().positions;
1561 if state.focused && !state.occluded && state.read_only_focus.is_none() && anchor == focus {
1653 if state.focused && !state.occluded && state.object_focus.is_none() && anchor == focus {
15621654 let now = Instant::now();
15631655 if now >= state.blink_at {
15641656 state.caret = !state.caret;
......@@ -1588,6 +1680,11 @@ enum Hit {
15881680 grab: [f32; 2],
15891681 },
15901682 ReadOnly(usize),
1683 /// `handle` is [0, 0] on the picture and a direction on the selected picture's handles.
1684 Image {
1685 id: onestore::ExGuid,
1686 handle: [i8; 2],
1687 },
15911688}
15921689
15931690fn page_hit_test(
......@@ -1696,6 +1793,7 @@ fn page_hit_test(
16961793 canvas::gpu::page::SceneHit::Outline(hit) => Some(hit),
16971794 canvas::gpu::page::SceneHit::Date(field) => Some(Hit::Date(field)),
16981795 canvas::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)),
1796 canvas::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }),
16991797 }
17001798 };
17011799 [Layer::Grips, Layer::Body, Layer::Below]
......@@ -1707,7 +1805,7 @@ fn page_primitives<'a>(
17071805 editor: &'a CanvasEditor,
17081806 scene: Option<&'a (PageScene, [f32; 2])>,
17091807 preview: Option<PointerFeedback<'a>>,
1710 read_only_focus: Option<usize>,
1808 object_focus: Option<ObjectFocus>,
17111809 show_caret: bool,
17121810 scale: f32,
17131811 display_scale: f32,
......@@ -1726,7 +1824,7 @@ fn page_primitives<'a>(
17261824 Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin,
17271825 _ => outline.origin(),
17281826 };
1729 if (read_only_focus.is_none() && outline.id == editor.active_outline().id)
1827 if (object_focus.is_none() && outline.id == editor.active_outline().id)
17301828 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)
17311829 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)
17321830 {
......@@ -1738,7 +1836,7 @@ fn page_primitives<'a>(
17381836 );
17391837 }
17401838 append_outline(
1741 (read_only_focus.is_none()
1839 (object_focus.is_none()
17421840 && outline.id == editor.active_outline().id
17431841 && !matches!(preview, Some(PointerFeedback::Resize(_))))
17441842 .then_some(editor),
......@@ -1751,7 +1849,11 @@ fn page_primitives<'a>(
17511849 )
17521850 };
17531851 if let Some((scene, origin)) = scene {
1754 scene.append_primitives_with(&mut primitives, *origin, Some(editor), &paint)?;
1852 let moving = match preview {
1853 Some(PointerFeedback::Image(id, origin, size)) => Some((id, image_rect(origin, size))),
1854 _ => None,
1855 };
1856 scene.append_primitives_with(&mut primitives, *origin, Some(editor), moving, &paint)?;
17551857 }
17561858 for outline in editor
17571859 .visible_outlines()
......@@ -1759,7 +1861,7 @@ fn page_primitives<'a>(
17591861 {
17601862 paint(outline.id, [0.0; 2], &mut primitives)?;
17611863 }
1762 if read_only_focus.is_none()
1864 if object_focus.is_none()
17631865 && let Some(outline) = editor.caret_outline()
17641866 {
17651867 append_outline(
......@@ -1772,7 +1874,20 @@ fn page_primitives<'a>(
17721874 &mut primitives,
17731875 )?;
17741876 }
1775 if let Some(index) = read_only_focus {
1877 if let Some(ObjectFocus::Image(id)) = object_focus {
1878 let (origin, size) = match preview {
1879 Some(PointerFeedback::Image(moving, origin, size)) if moving == id => (origin, size),
1880 _ => editor
1881 .image_placement(id)
1882 .ok_or("The selected picture is missing.")?,
1883 };
1884 append_image_chrome(
1885 image_rect(origin, size),
1886 display_scale / scale,
1887 &mut primitives,
1888 );
1889 }
1890 if let Some(ObjectFocus::ReadOnly(index)) = object_focus {
17761891 let (scene, offset) = scene.unwrap();
17771892 let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect();
17781893 let [x0, y0, x1, y1] = [
......@@ -1797,6 +1912,124 @@ fn page_primitives<'a>(
17971912 Ok(primitives)
17981913}
17991914
1915/// Native picture handles sit on a selection border drawn 5 px outside the picture, named
1916/// by their direction from its center.
1917fn image_handles(rect: [f32; 4], pixel: f32) -> impl Iterator<Item = ([i8; 2], [f32; 2])> {
1918 let border = [
1919 rect[0] - 5.0 * pixel,
1920 rect[1] - 5.0 * pixel,
1921 rect[2] + 5.0 * pixel,
1922 rect[3] + 5.0 * pixel,
1923 ];
1924 [-1, 0, 1]
1925 .into_iter()
1926 .flat_map(|y| [-1, 0, 1].map(|x| [x, y]))
1927 .filter(|handle| *handle != [0, 0])
1928 .map(move |handle| {
1929 (
1930 handle,
1931 std::array::from_fn(|axis| {
1932 let [start, end] = [border[axis], border[axis + 2]];
1933 start + (end - start) * f32::from(handle[axis] + 1) * 0.5
1934 }),
1935 )
1936 })
1937}
1938
1939fn image_handle_at(rect: [f32; 4], pixel: f32, point: [f32; 2]) -> Option<[i8; 2]> {
1940 image_handles(rect, pixel)
1941 .find(|(_, center)| (0..2).all(|axis| (point[axis] - center[axis]).abs() <= 5.0 * pixel))
1942 .map(|(handle, _)| handle)
1943}
1944
1945fn handle_cursor(handle: [i8; 2]) -> winit::window::CursorIcon {
1946 use winit::window::CursorIcon;
1947 match handle {
1948 [0, 0] => CursorIcon::Move,
1949 [_, 0] => CursorIcon::EwResize,
1950 [0, _] => CursorIcon::NsResize,
1951 [x, y] if x == y => CursorIcon::NwseResize,
1952 _ => CursorIcon::NeswResize,
1953 }
1954}
1955
1956fn image_rect(origin: [f32; 2], size: [f32; 2]) -> [f32; 4] {
1957 [
1958 origin[0],
1959 origin[1],
1960 origin[0] + size[0],
1961 origin[1] + size[1],
1962 ]
1963}
1964
1965/// Drags a picture's `handle` by `delta`: edges stretch one axis, corners keep the aspect
1966/// ratio at the larger of the two scales, and the opposite side stays fixed.
1967fn resize_image(
1968 origin: [f32; 2],
1969 size: [f32; 2],
1970 handle: [i8; 2],
1971 delta: [f32; 2],
1972) -> ([f32; 2], [f32; 2]) {
1973 let mut scale: [f32; 2] = std::array::from_fn(|axis| {
1974 (size[axis] + f32::from(handle[axis]) * delta[axis]) / size[axis]
1975 });
1976 if !handle.contains(&0) {
1977 scale = [scale[0].max(scale[1]); 2];
1978 }
1979 let resized: [f32; 2] = std::array::from_fn(|axis| (size[axis] * scale[axis]).max(1.0));
1980 let origin = std::array::from_fn(|axis| {
1981 if handle[axis] < 0 {
1982 origin[axis] + size[axis] - resized[axis]
1983 } else {
1984 origin[axis]
1985 }
1986 });
1987 (origin, resized)
1988}
1989
1990fn append_image_chrome(rect: [f32; 4], pixel: f32, primitives: &mut Vec<Primitive<'_>>) {
1991 let mut tint = canvas::gpu::colorref(0x00e0d2e6);
1992 // Native pictures take this tint at 25% in sRGB; 10% in linear light matches it.
1993 tint[3] = 0.1;
1994 primitives.push(Primitive::Rect { rect, color: tint });
1995 primitives.push(Primitive::RoundedRect {
1996 rect: [
1997 rect[0] - 5.0 * pixel,
1998 rect[1] - 5.0 * pixel,
1999 rect[2] + 5.0 * pixel,
2000 rect[3] + 5.0 * pixel,
2001 ],
2002 radius: [0.0; 2],
2003 stroke: Some(Stroke::Dashed(pixel)),
2004 color: canvas::gpu::colorref(0x00ff9a31),
2005 });
2006 for (handle, [x, y]) in image_handles(rect, pixel) {
2007 let (half, radius) = if handle.contains(&0) {
2008 (3.5, 0.0)
2009 } else {
2010 (4.0, 4.0)
2011 };
2012 let rect = [
2013 x - half * pixel,
2014 y - half * pixel,
2015 x + half * pixel,
2016 y + half * pixel,
2017 ];
2018 primitives.push(Primitive::RoundedRect {
2019 rect,
2020 radius: [radius * pixel; 2],
2021 stroke: None,
2022 color: canvas::gpu::colorref(0x00ffefe7),
2023 });
2024 primitives.push(Primitive::RoundedRect {
2025 rect,
2026 radius: [radius * pixel; 2],
2027 stroke: Some(Stroke::Solid(pixel)),
2028 color: canvas::gpu::colorref(0x00dea67b),
2029 });
2030 }
2031}
2032
18002033fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) -> Selection {
18012034 if unit == SelectionUnit::Grapheme {
18022035 return Selection {
......@@ -2500,6 +2733,33 @@ mod tests {
25002733 );
25012734 }
25022735
2736 #[test]
2737 fn picture_handles_resize_as_onenote_does() {
2738 // Native drags of the 333 x 200.1 pt mockup on "av: casual night in the trees".
2739 let (origin, size) = ([468.0, 86.4], [333.0, 200.1]);
2740 let (corner_origin, corner) = resize_image(origin, size, [1, -1], [-75.0, 30.75]);
2741 assert!((corner[0] - 281.827).abs() < 0.01 && (corner[1] - 169.351).abs() < 0.01);
2742 assert!((corner_origin[1] - 117.15).abs() < 0.01 && corner_origin[0] == 468.0);
2743 assert_eq!(
2744 resize_image(origin, size, [1, 0], [46.5, 10.0]),
2745 (origin, [379.5, 200.1])
2746 );
2747 let (left_origin, left) = resize_image(origin, size, [-1, 0], [400.0, 0.0]);
2748 assert_eq!(left, [1.0, 200.1]);
2749 assert_eq!(left_origin[0], 468.0 + 333.0 - 1.0);
2750 let pixel = 0.75;
2751 let rect = image_rect(origin, size);
2752 assert_eq!(
2753 image_handle_at(rect, pixel, [468.0 - 3.75, 86.4 - 3.75]),
2754 Some([-1, -1])
2755 );
2756 assert_eq!(
2757 image_handle_at(rect, pixel, [468.0 + 166.5, 286.5 + 3.75]),
2758 Some([0, 1])
2759 );
2760 assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0]), None);
2761 }
2762
25032763 #[test]
25042764 fn grouped_drag_keeps_the_initial_word_when_reversing_direction() {
25052765 let selection = |start, end| {
......@@ -3257,7 +3517,16 @@ mod tests {
32573517 .any(|(_, color)| *color == macos::text_colors()[0])
32583518 );
32593519 let selected = rectangles(
3260 page_primitives(&editor, Some(&scene), None, Some(0), true, scale, 1.0).unwrap(),
3520 page_primitives(
3521 &editor,
3522 Some(&scene),
3523 None,
3524 Some(ObjectFocus::ReadOnly(0)),
3525 true,
3526 scale,
3527 1.0,
3528 )
3529 .unwrap(),
32613530 );
32623531 assert!(
32633532 !selected
......@@ -3267,8 +3536,16 @@ mod tests {
32673536 assert_eq!(
32683537 selected,
32693538 rectangles(
3270 page_primitives(&editor, Some(&scene), None, Some(0), false, scale, 1.0)
3271 .unwrap()
3539 page_primitives(
3540 &editor,
3541 Some(&scene),
3542 None,
3543 Some(ObjectFocus::ReadOnly(0)),
3544 false,
3545 scale,
3546 1.0
3547 )
3548 .unwrap()
32723549 )
32733550 );
32743551 assert_eq!(
......@@ -3285,7 +3562,16 @@ mod tests {
32853562 .any(|(_, color)| *color == macos::text_colors()[1])
32863563 );
32873564 let selected = rectangles(
3288 page_primitives(&editor, Some(&scene), None, Some(0), true, 1.0, 1.0).unwrap(),
3565 page_primitives(
3566 &editor,
3567 Some(&scene),
3568 None,
3569 Some(ObjectFocus::ReadOnly(0)),
3570 true,
3571 1.0,
3572 1.0,
3573 )
3574 .unwrap(),
32893575 );
32903576 assert!(
32913577 !selected
tools/canvas/README.md+2-2
......@@ -115,7 +115,7 @@ The host sleeps its caret timer while macOS reports the window occluded and requ
115115"target/Snowbound.app/Contents/MacOS/Snowbound" --page PRIVATE_SECTION_COPY EXACT_PAGE_TITLE --substitute-font CARLITO_REGULAR_FILE
116116```
117117
118`--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.
118`--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.
119119
120120`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.
121121
......@@ -129,7 +129,7 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA
129129
130130Unsupported 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.
131131
132Placeholders 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.
132Placeholders 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.
133133
134134Document and layout values are read-only outside the editor; edits update their retained geometry together. `CanvasEditor::from_outlines` remains available for text-only construction.
135135