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 {...@@ -620,6 +620,10 @@ enum History {
620 outline: ExGuid,620 outline: ExGuid,
621 layout: onestore::document::Layout,621 layout: onestore::document::Layout,
622 },622 },
623 Image {
624 image: ExGuid,
625 layout: onestore::document::Layout,
626 },
623 Remove {627 Remove {
624 outline: onestore::ExGuid,628 outline: onestore::ExGuid,
625 focus: RestoreFocus,629 focus: RestoreFocus,
...@@ -1271,6 +1275,62 @@ impl CanvasEditor {...@@ -1271,6 +1275,62 @@ impl CanvasEditor {
1271 Ok(())1275 Ok(())
1272 }1276 }
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
1274 pub fn selection(&self) -> Selection {1334 pub fn selection(&self) -> Selection {
1275 self.active_outline().selection1335 self.active_outline().selection
1276 }1336 }
...@@ -2249,6 +2309,7 @@ impl CanvasEditor {...@@ -2249,6 +2309,7 @@ impl CanvasEditor {
2249 | History::Layout { outline, .. } => {2309 | History::Layout { outline, .. } => {
2250 self.outlines.iter().any(|item| item.id == *outline)2310 self.outlines.iter().any(|item| item.id == *outline)
2251 }2311 }
2312 History::Image { image, .. } => self.image(*image).is_some(),
2252 History::Remove { outline, focus } => {2313 History::Remove { outline, focus } => {
2253 self.outlines.iter().any(|item| item.id == *outline)2314 self.outlines.iter().any(|item| item.id == *outline)
2254 && match focus {2315 && match focus {
...@@ -2396,6 +2457,10 @@ impl CanvasEditor {...@@ -2396,6 +2457,10 @@ impl CanvasEditor {
2396 layout: previous,2457 layout: previous,
2397 }2458 }
2398 }2459 }
2460 History::Image { image, layout } => History::Image {
2461 image,
2462 layout: std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout),
2463 },
2399 History::Remove { outline, focus } => {2464 History::Remove { outline, focus } => {
2400 let index = self2465 let index = self
2401 .outlines2466 .outlines
...@@ -4330,6 +4395,87 @@ mod tests {...@@ -4330,6 +4395,87 @@ mod tests {
4330 }4395 }
4331 }4396 }
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
4333 #[test]4479 #[test]
4334 fn title_flow_moves_page_objects_atomically_and_cancels_composition() {4480 fn title_flow_moves_page_objects_atomically_and_cancels_composition() {
4335 use onestore::page::{Image, Page, PageObject, Title};4481 use onestore::page::{Image, Page, PageObject, Title};
crates/canvas/src/gpu/page.rs+97-9
...@@ -20,6 +20,7 @@ pub enum SceneHit<T> {...@@ -20,6 +20,7 @@ pub enum SceneHit<T> {
20 Outline(T),20 Outline(T),
21 Date(DateField),21 Date(DateField),
22 ReadOnly(usize),22 ReadOnly(usize),
23 Image(onestore::ExGuid),
23}24}
2425
25fn outline_origin(26fn outline_origin(
...@@ -283,6 +284,19 @@ impl PageScene {...@@ -283,6 +284,19 @@ impl PageScene {
283 return Some(SceneHit::ReadOnly(readonly));284 return Some(SceneHit::ReadOnly(readonly));
284 }285 }
285 }286 }
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 }
286 Content::Outline { .. } | Content::Image(_) => {}300 Content::Outline { .. } | Content::Image(_) => {}
287 }301 }
288 }302 }
...@@ -295,16 +309,18 @@ impl PageScene {...@@ -295,16 +309,18 @@ impl PageScene {
295 primitives: &mut Vec<Primitive<'a>>,309 primitives: &mut Vec<Primitive<'a>>,
296 offset: [f32; 2],310 offset: [f32; 2],
297 ) -> Result<(), SceneError> {311 ) -> Result<(), SceneError> {
298 self.append_primitives_with(primitives, offset, None, |_, _, _| {312 self.append_primitives_with(primitives, offset, None, None, |_, _, _| {
299 Err(SceneError::MissingOutline)313 Err(SceneError::MissingOutline)
300 })314 })
301 }315 }
302316
317 /// `moving` draws one picture at a previewed rectangle instead of its stored layout.
303 pub fn append_primitives_with<'a, E: From<SceneError>>(318 pub fn append_primitives_with<'a, E: From<SceneError>>(
304 &'a self,319 &'a self,
305 primitives: &mut Vec<Primitive<'a>>,320 primitives: &mut Vec<Primitive<'a>>,
306 offset: [f32; 2],321 offset: [f32; 2],
307 editor: Option<&'a CanvasEditor>,322 editor: Option<&'a CanvasEditor>,
323 moving: Option<(onestore::ExGuid, [f32; 4])>,
308 mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec<Primitive<'a>>) -> Result<(), E>,324 mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec<Primitive<'a>>) -> Result<(), E>,
309 ) -> Result<(), E> {325 ) -> Result<(), E> {
310 for content in self.objects(editor)? {326 for content in self.objects(editor)? {
...@@ -373,14 +389,22 @@ impl PageScene {...@@ -373,14 +389,22 @@ impl PageScene {
373 .images389 .images
374 .get(&source.id)390 .get(&source.id)
375 .ok_or(SceneError::MissingImage)?,391 .ok_or(SceneError::MissingImage)?,
376 rect: [392 rect: match moving {
377 object_origin[0],393 Some((id, [x0, y0, x1, y1])) if id == source.id => [
378 object_origin[1],394 x0 + offset[0],
379 object_origin[0]395 y0 + offset[1],
380 + source.layout.max_width.ok_or(SceneError::MissingImage)?,396 x1 + offset[0],
381 object_origin[1]397 y1 + offset[1],
382 + source.layout.max_height.ok_or(SceneError::MissingImage)?,398 ],
383 ],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 },
384 }),408 }),
385 Content::Outline { .. } | Content::Date { .. } => {409 Content::Outline { .. } | Content::Date { .. } => {
386 let outline = match content {410 let outline = match content {
...@@ -930,6 +954,7 @@ mod tests {...@@ -930,6 +954,7 @@ mod tests {
930 &mut primitives,954 &mut primitives,
931 [0.0; 2],955 [0.0; 2],
932 Some(editor),956 Some(editor),
957 None,
933 |_, _, _| Ok(()),958 |_, _, _| Ok(()),
934 )959 )
935 .unwrap();960 .unwrap();
...@@ -1327,6 +1352,7 @@ mod tests {...@@ -1327,6 +1352,7 @@ mod tests {
1327 &mut primitives,1352 &mut primitives,
1328 [0.0; 2],1353 [0.0; 2],
1329 Some(editor),1354 Some(editor),
1355 None,
1330 |id, offset, primitives| {1356 |id, offset, primitives| {
1331 let outline = editor1357 let outline = editor
1332 .outlines()1358 .outlines()
...@@ -1561,4 +1587,66 @@ mod tests {...@@ -1561,4 +1587,66 @@ mod tests {
1561 assert_eq!(first.pixels.id(), second.pixels.id());1587 assert_eq!(first.pixels.id(), second.pixels.id());
1562 assert_eq!(first.pixels.as_ref(), [255, 0, 0, 255]);1588 assert_eq!(first.pixels.as_ref(), [255, 0, 0, 255]);
1563 }1589 }
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 }
1564}1652}
crates/snowbound/src/main.rs+367-81
...@@ -123,6 +123,13 @@ enum Drag {...@@ -123,6 +123,13 @@ enum Drag {
123 grab: [f32; 2],123 grab: [f32; 2],
124 pending_press: Option<[f32; 2]>,124 pending_press: Option<[f32; 2]>,
125 },125 },
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 },
126}133}
127134
128#[derive(Clone, Copy)]135#[derive(Clone, Copy)]
...@@ -130,6 +137,24 @@ enum PointerFeedback<'a> {...@@ -130,6 +137,24 @@ enum PointerFeedback<'a> {
130 Hover(onestore::ExGuid),137 Hover(onestore::ExGuid),
131 Move(onestore::ExGuid, [f32; 2]),138 Move(onestore::ExGuid, [f32; 2]),
132 Resize(&'a TextOutline),139 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 }
133}158}
134159
135struct State {160struct State {
...@@ -151,7 +176,7 @@ struct State {...@@ -151,7 +176,7 @@ struct State {
151 pointer_inside: bool,176 pointer_inside: bool,
152 last_click: Option<(Instant, [f32; 2], u8)>,177 last_click: Option<(Instant, [f32; 2], u8)>,
153 drag: Option<Drag>,178 drag: Option<Drag>,
154 read_only_focus: Option<usize>,179 object_focus: Option<ObjectFocus>,
155 modifiers: ModifiersState,180 modifiers: ModifiersState,
156 focused: bool,181 focused: bool,
157 occluded: bool,182 occluded: bool,
...@@ -310,7 +335,7 @@ impl State {...@@ -310,7 +335,7 @@ impl State {
310 pointer_inside: false,335 pointer_inside: false,
311 last_click: None,336 last_click: None,
312 drag: None,337 drag: None,
313 read_only_focus: None,338 object_focus: None,
314 modifiers: ModifiersState::empty(),339 modifiers: ModifiersState::empty(),
315 focused: true,340 focused: true,
316 occluded: false,341 occluded: false,
...@@ -352,15 +377,75 @@ impl State {...@@ -352,15 +377,75 @@ impl State {
352 ))377 ))
353 }378 }
354379
355 fn set_read_only_focus(&mut self, index: Option<usize>) {380 fn image_preview(&self) -> Option<(onestore::ExGuid, [f32; 2], [f32; 2])> {
356 if self.read_only_focus != index {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 {
357 self.editor.finish_composition();420 self.editor.finish_composition();
358 self.drag = None;421 self.drag = None;
359 self.read_only_focus = index;422 self.object_focus = focus;
360 self.window.set_ime_allowed(index.is_none());423 self.window.set_ime_allowed(focus.is_none());
361 }424 }
362 }425 }
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
364 fn changed(&mut self) -> Result<(), Box<dyn Error>> {449 fn changed(&mut self) -> Result<(), Box<dyn Error>> {
365 self.scroll().clamp(&mut self.viewport);450 self.scroll().clamp(&mut self.viewport);
366 self.caret = true;451 self.caret = true;
...@@ -446,7 +531,7 @@ impl State {...@@ -446,7 +531,7 @@ impl State {
446 self.editor = editor;531 self.editor = editor;
447 self.scene = Some((scene, [0.0; 2]));532 self.scene = Some((scene, [0.0; 2]));
448 self.drag = None;533 self.drag = None;
449 self.read_only_focus = None;534 self.object_focus = None;
450 self.window.set_title(&session.window_title());535 self.window.set_title(&session.window_title());
451 self.update_accessibility()?;536 self.update_accessibility()?;
452 self.window.request_redraw();537 self.window.request_redraw();
...@@ -535,7 +620,7 @@ impl State {...@@ -535,7 +620,7 @@ impl State {
535 self.scene.as_ref(),620 self.scene.as_ref(),
536 &self.editor,621 &self.editor,
537 self.viewport,622 self.viewport,
538 self.read_only_focus,623 self.object_focus.and_then(ObjectFocus::read_only),
539 );624 );
540 update625 update
541 }626 }
...@@ -573,7 +658,7 @@ impl State {...@@ -573,7 +658,7 @@ impl State {
573 }658 }
574 if let Some(index) = self.accessibility.read_only_for_node(request.target_node) {659 if let Some(index) = self.accessibility.read_only_for_node(request.target_node) {
575 if request.action == Action::Focus {660 if request.action == Action::Focus {
576 self.set_read_only_focus(Some(index));661 self.set_object_focus(Some(ObjectFocus::ReadOnly(index)));
577 self.window.focus_window();662 self.window.focus_window();
578 self.reveal_focus()?;663 self.reveal_focus()?;
579 self.changed()?;664 self.changed()?;
...@@ -605,7 +690,7 @@ impl State {...@@ -605,7 +690,7 @@ impl State {
605 }690 }
606 _ => return Ok(()),691 _ => return Ok(()),
607 }692 }
608 self.set_read_only_focus(None);693 self.set_object_focus(None);
609 self.reveal_focus()?;694 self.reveal_focus()?;
610 self.changed()695 self.changed()
611 }696 }
...@@ -614,19 +699,9 @@ impl State {...@@ -614,19 +699,9 @@ impl State {
614 if self.viewport.size.contains(&0) {699 if self.viewport.size.contains(&0) {
615 return Ok(());700 return Ok(());
616 }701 }
617 let rect = if let Some(index) = self.read_only_focus {702 let rect = if let Some(focus) = self.object_focus {
618 let (scene, offset) = self.scene.as_ref().unwrap();703 let [x0, y0, x1, y1] = self.object_rect(focus).map(f64::from);
619 let [x0, y0, x1, y1] = scene704 parley::BoundingBox { x0, y0, x1, y1 }
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 }
630 } else {705 } else {
631 let mut rect = self.editor.caret(1.0)?;706 let mut rect = self.editor.caret(1.0)?;
632 let origin = self.editor.active_outline().origin();707 let origin = self.editor.active_outline().origin();
...@@ -647,7 +722,7 @@ impl State {...@@ -647,7 +722,7 @@ impl State {
647 [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis];722 [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis];
648 let outline_start = outline_start.min(start);723 let outline_start = outline_start.min(start);
649 let outline_end = outline_end.max(end);724 let outline_end = outline_end.max(end);
650 if self.read_only_focus.is_none()725 if self.object_focus.is_none()
651 && (outline_end - outline_start) * f64::from(self.viewport.scale)726 && (outline_end - outline_start) * f64::from(self.viewport.scale)
652 <= size - margin * 2.0727 <= size - margin * 2.0
653 {728 {
...@@ -759,6 +834,10 @@ impl State {...@@ -759,6 +834,10 @@ impl State {
759 _ => self834 _ => self
760 .preview()835 .preview()
761 .map(|(id, origin)| PointerFeedback::Move(id, origin))836 .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 })
762 .or_else(|| {841 .or_else(|| {
763 if !self.pointer_inside {842 if !self.pointer_inside {
764 return None;843 return None;
...@@ -778,10 +857,13 @@ impl State {...@@ -778,10 +857,13 @@ impl State {
778 }857 }
779 }),858 }),
780 },859 },
781 self.read_only_focus,860 self.object_focus,
782 self.caret861 self.caret
783 && self.focused862 && 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 ),
785 self.viewport.scale,867 self.viewport.scale,
786 self.display_scale,868 self.display_scale,
787 )?;869 )?;
...@@ -807,12 +889,15 @@ impl State {...@@ -807,12 +889,15 @@ impl State {
807 let shift = self.modifiers.shift_key();889 let shift = self.modifiers.shift_key();
808 let command = self.modifiers.super_key();890 let command = self.modifiers.super_key();
809 let option = self.modifiers.alt_key();891 let option = self.modifiers.alt_key();
810 if self.read_only_focus.is_some() {892 if let Some(focus) = self.object_focus {
811 if !read_only_shortcut(key, self.modifiers) {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) {
812 return Ok(());897 return Ok(());
813 }898 }
814 if key == &Key::Named(NamedKey::Escape) {899 if key == &Key::Named(NamedKey::Escape) {
815 self.set_read_only_focus(None);900 self.set_object_focus(None);
816 self.reveal_focus()?;901 self.reveal_focus()?;
817 return self.changed();902 return self.changed();
818 }903 }
...@@ -832,7 +917,10 @@ impl State {...@@ -832,7 +917,10 @@ impl State {
832 _ => {}917 _ => {}
833 }918 }
834 }919 }
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 ) {
836 if matches!(924 if matches!(
837 key,925 key,
838 Key::Named(926 Key::Named(
...@@ -881,14 +969,17 @@ impl State {...@@ -881,14 +969,17 @@ impl State {
881 if count == 0 {969 if count == 0 {
882 return self.changed();970 return self.changed();
883 }971 }
884 let index = self.read_only_focus.map_or_else(972 let index = self
885 || {973 .object_focus
886 outlines974 .and_then(ObjectFocus::read_only)
887 .iter()975 .map_or_else(
888 .position(|outline| outline.id == self.editor.active_outline().id)976 || {
889 },977 outlines
890 |index| Some(outlines.len() + index),978 .iter()
891 );979 .position(|outline| outline.id == self.editor.active_outline().id)
980 },
981 |index| Some(outlines.len() + index),
982 );
892 let next = index.map_or(if shift { count - 1 } else { 0 }, |index| {983 let next = index.map_or(if shift { count - 1 } else { 0 }, |index| {
893 if shift {984 if shift {
894 (index + count - 1) % count985 (index + count - 1) % count
...@@ -898,9 +989,9 @@ impl State {...@@ -898,9 +989,9 @@ impl State {
898 });989 });
899 if next < outlines.len() {990 if next < outlines.len() {
900 self.editor.focus_outline(outlines[next].id)?;991 self.editor.focus_outline(outlines[next].id)?;
901 self.set_read_only_focus(None);992 self.set_object_focus(None);
902 } else {993 } else {
903 self.set_read_only_focus(Some(next - outlines.len()));994 self.set_object_focus(Some(ObjectFocus::ReadOnly(next - outlines.len())));
904 }995 }
905 self.reveal_focus()?;996 self.reveal_focus()?;
906 return self.changed();997 return self.changed();
...@@ -908,14 +999,9 @@ impl State {...@@ -908,14 +999,9 @@ impl State {
908 if command && let Key::Character(key) = key {999 if command && let Key::Character(key) = key {
909 match key.to_lowercase().as_str() {1000 match key.to_lowercase().as_str() {
910 "n" if shift => {1001 "n" if shift => {
911 let position = if let Some(index) = self.read_only_focus {1002 let position = if let Some(focus) = self.object_focus {
912 let (scene, offset) = self.scene.as_ref().unwrap();1003 let rect = self.object_rect(focus);
913 let rect = scene1004 [rect[2] + 24.0, rect[1]]
914 .read_only(Some(&self.editor))
915 .nth(index)
916 .unwrap()
917 .rect();
918 [rect[2] + offset[0] + 24.0, rect[1] + offset[1]]
919 } else {1005 } else {
920 let bounds = self.editor.active_outline().bounds();1006 let bounds = self.editor.active_outline().bounds();
921 [bounds.x1 as f32 + 24.0, bounds.y0 as f32]1007 [bounds.x1 as f32 + 24.0, bounds.y0 as f32]
...@@ -925,7 +1011,7 @@ impl State {...@@ -925,7 +1011,7 @@ impl State {
925 snap_to_grid(position),1011 snap_to_grid(position),
926 DEFAULT_OUTLINE_WIDTH,1012 DEFAULT_OUTLINE_WIDTH,
927 )?;1013 )?;
928 self.set_read_only_focus(None);1014 self.set_object_focus(None);
929 }1015 }
930 "a" => self.editor.select_all()?,1016 "a" => self.editor.select_all()?,
931 "z" => {1017 "z" => {
...@@ -1137,7 +1223,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -1137,7 +1223,7 @@ impl ApplicationHandler<UserEvent> for App {
1137 let event = match event {1223 let event = match event {
1138 UserEvent::InsertText(text) => {1224 UserEvent::InsertText(text) => {
1139 if let Some(state) = &mut self.state1225 if let Some(state) = &mut self.state
1140 && state.read_only_focus.is_none()1226 && state.object_focus.is_none()
1141 && !text.is_empty()1227 && !text.is_empty()
1142 && !text.chars().any(char::is_control)1228 && !text.chars().any(char::is_control)
1143 {1229 {
...@@ -1282,12 +1368,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -1282,12 +1368,7 @@ impl ApplicationHandler<UserEvent> for App {
1282 WindowEvent::CursorMoved { position, .. } => {1368 WindowEvent::CursorMoved { position, .. } => {
1283 state.pointer_inside = true;1369 state.pointer_inside = true;
1284 state.pointer = [position.x as f32, position.y as f32];1370 state.pointer = [position.x as f32, position.y as f32];
1285 let hit = page_hit_test(1371 let hit = state.hit_test(state.viewport.document_point(state.pointer));
1286 &state.editor,
1287 state.scene.as_ref(),
1288 state.viewport.document_point(state.pointer),
1289 state.display_scale / state.viewport.scale,
1290 );
1291 let scrollbar = state1372 let scrollbar = state
1292 .scroll()1373 .scroll()
1293 .hit_test(state.viewport, state.display_scale, state.pointer)1374 .hit_test(state.viewport, state.display_scale, state.pointer)
...@@ -1295,6 +1376,8 @@ impl ApplicationHandler<UserEvent> for App {...@@ -1295,6 +1376,8 @@ impl ApplicationHandler<UserEvent> for App {
1295 state.window.set_cursor(match (&state.drag, hit) {1376 state.window.set_cursor(match (&state.drag, hit) {
1296 (Some(Drag::Scrollbar { .. }), _) => winit::window::CursorIcon::Default,1377 (Some(Drag::Scrollbar { .. }), _) => winit::window::CursorIcon::Default,
1297 (None, _) if scrollbar => winit::window::CursorIcon::Default,1378 (None, _) if scrollbar => winit::window::CursorIcon::Default,
1379 (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle),
1380 (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle),
1298 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => {1381 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => {
1299 winit::window::CursorIcon::EwResize1382 winit::window::CursorIcon::EwResize
1300 }1383 }
...@@ -1330,7 +1413,9 @@ impl ApplicationHandler<UserEvent> for App {...@@ -1330,7 +1413,9 @@ impl ApplicationHandler<UserEvent> for App {
1330 .select(drag_selection(*anchor, target, *unit))?;1413 .select(drag_selection(*anchor, target, *unit))?;
1331 state.changed()?;1414 state.changed()?;
1332 }1415 }
1333 Some(Drag::Outline { pending_press, .. }) => {1416 Some(
1417 Drag::Outline { pending_press, .. } | Drag::Image { pending_press, .. },
1418 ) => {
1334 if pending_press.is_some_and(|press| {1419 if pending_press.is_some_and(|press| {
1335 (0..2).any(|axis| {1420 (0..2).any(|axis| {
1336 (state.pointer[axis] - press[axis]).abs()1421 (state.pointer[axis] - press[axis]).abs()
...@@ -1387,18 +1472,22 @@ impl ApplicationHandler<UserEvent> for App {...@@ -1387,18 +1472,22 @@ impl ApplicationHandler<UserEvent> for App {
1387 if scrollbar.is_some() {1472 if scrollbar.is_some() {
1388 state.drag = scrollbar;1473 state.drag = scrollbar;
1389 } else {1474 } else {
1390 match page_hit_test(1475 match state.hit_test(point) {
1391 &state.editor,
1392 state.scene.as_ref(),
1393 point,
1394 state.display_scale / state.viewport.scale,
1395 ) {
1396 Some(Hit::Date(field)) => state.edit_date(field)?,1476 Some(Hit::Date(field)) => state.edit_date(field)?,
1397 Some(Hit::ReadOnly(index)) => {1477 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 });
1399 }1488 }
1400 Some(Hit::Handle { id, grab }) => {1489 Some(Hit::Handle { id, grab }) => {
1401 state.set_read_only_focus(None);1490 state.set_object_focus(None);
1402 state.editor.focus_outline(id)?;1491 state.editor.focus_outline(id)?;
1403 state.drag = Some(Drag::Outline {1492 state.drag = Some(Drag::Outline {
1404 id,1493 id,
...@@ -1407,7 +1496,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -1407,7 +1496,7 @@ impl ApplicationHandler<UserEvent> for App {
1407 });1496 });
1408 }1497 }
1409 Some(Hit::Resize { id, grab }) => {1498 Some(Hit::Resize { id, grab }) => {
1410 state.set_read_only_focus(None);1499 state.set_object_focus(None);
1411 state.editor.focus_outline(id)?;1500 state.editor.focus_outline(id)?;
1412 state.drag = Some(Drag::Resize {1501 state.drag = Some(Drag::Resize {
1413 outline: None,1502 outline: None,
...@@ -1416,9 +1505,9 @@ impl ApplicationHandler<UserEvent> for App {...@@ -1416,9 +1505,9 @@ impl ApplicationHandler<UserEvent> for App {
1416 }1505 }
1417 Some(Hit::Text { id, point }) => {1506 Some(Hit::Text { id, point }) => {
1418 let extend = state.modifiers.shift_key()1507 let extend = state.modifiers.shift_key()
1419 && state.read_only_focus.is_none()1508 && state.object_focus.is_none()
1420 && id == state.editor.active_outline().id;1509 && id == state.editor.active_outline().id;
1421 state.set_read_only_focus(None);1510 state.set_object_focus(None);
1422 state.editor.focus_outline(id)?;1511 state.editor.focus_outline(id)?;
1423 let previous = state.editor.selection();1512 let previous = state.editor.selection();
1424 state.editor.select_below(&mut state.engine, id, point)?;1513 state.editor.select_below(&mut state.engine, id, point)?;
...@@ -1464,7 +1553,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -1464,7 +1553,7 @@ impl ApplicationHandler<UserEvent> for App {
1464 position,1553 position,
1465 DEFAULT_OUTLINE_WIDTH,1554 DEFAULT_OUTLINE_WIDTH,
1466 )?;1555 )?;
1467 state.set_read_only_focus(None);1556 state.set_object_focus(None);
1468 state.drag = Some(Drag::Text {1557 state.drag = Some(Drag::Text {
1469 anchor: state.editor.selection(),1558 anchor: state.editor.selection(),
1470 unit: SelectionUnit::Grapheme,1559 unit: SelectionUnit::Grapheme,
...@@ -1474,6 +1563,9 @@ impl ApplicationHandler<UserEvent> for App {...@@ -1474,6 +1563,9 @@ impl ApplicationHandler<UserEvent> for App {
1474 }1563 }
1475 } else {1564 } else {
1476 let preview = state.preview();1565 let preview = state.preview();
1566 if let Some((id, origin, size)) = state.image_preview() {
1567 state.editor.place_image(id, origin, size)?;
1568 }
1477 if let Some(Drag::Resize {1569 if let Some(Drag::Resize {
1478 outline: Some(outline),1570 outline: Some(outline),
1479 ..1571 ..
...@@ -1508,7 +1600,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -1508,7 +1600,7 @@ impl ApplicationHandler<UserEvent> for App {
1508 {1600 {
1509 state.key(&event.logical_key, event.text.as_deref())?;1601 state.key(&event.logical_key, event.text.as_deref())?;
1510 }1602 }
1511 WindowEvent::Ime(_) if state.read_only_focus.is_some() => {}1603 WindowEvent::Ime(_) if state.object_focus.is_some() => {}
1512 WindowEvent::Ime(Ime::Preedit(text, cursor)) => {1604 WindowEvent::Ime(Ime::Preedit(text, cursor)) => {
1513 if text.is_empty() {1605 if text.is_empty() {
1514 state.editor.cancel_composition(&mut state.engine)?;1606 state.editor.cancel_composition(&mut state.engine)?;
...@@ -1558,7 +1650,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -1558,7 +1650,7 @@ impl ApplicationHandler<UserEvent> for App {
1558 return;1650 return;
1559 };1651 };
1560 let [anchor, focus] = state.editor.selection().positions;1652 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 {
1562 let now = Instant::now();1654 let now = Instant::now();
1563 if now >= state.blink_at {1655 if now >= state.blink_at {
1564 state.caret = !state.caret;1656 state.caret = !state.caret;
...@@ -1588,6 +1680,11 @@ enum Hit {...@@ -1588,6 +1680,11 @@ enum Hit {
1588 grab: [f32; 2],1680 grab: [f32; 2],
1589 },1681 },
1590 ReadOnly(usize),1682 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 },
1591}1688}
15921689
1593fn page_hit_test(1690fn page_hit_test(
...@@ -1696,6 +1793,7 @@ fn page_hit_test(...@@ -1696,6 +1793,7 @@ fn page_hit_test(
1696 canvas::gpu::page::SceneHit::Outline(hit) => Some(hit),1793 canvas::gpu::page::SceneHit::Outline(hit) => Some(hit),
1697 canvas::gpu::page::SceneHit::Date(field) => Some(Hit::Date(field)),1794 canvas::gpu::page::SceneHit::Date(field) => Some(Hit::Date(field)),
1698 canvas::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)),1795 canvas::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)),
1796 canvas::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }),
1699 }1797 }
1700 };1798 };
1701 [Layer::Grips, Layer::Body, Layer::Below]1799 [Layer::Grips, Layer::Body, Layer::Below]
...@@ -1707,7 +1805,7 @@ fn page_primitives<'a>(...@@ -1707,7 +1805,7 @@ fn page_primitives<'a>(
1707 editor: &'a CanvasEditor,1805 editor: &'a CanvasEditor,
1708 scene: Option<&'a (PageScene, [f32; 2])>,1806 scene: Option<&'a (PageScene, [f32; 2])>,
1709 preview: Option<PointerFeedback<'a>>,1807 preview: Option<PointerFeedback<'a>>,
1710 read_only_focus: Option<usize>,1808 object_focus: Option<ObjectFocus>,
1711 show_caret: bool,1809 show_caret: bool,
1712 scale: f32,1810 scale: f32,
1713 display_scale: f32,1811 display_scale: f32,
...@@ -1726,7 +1824,7 @@ fn page_primitives<'a>(...@@ -1726,7 +1824,7 @@ fn page_primitives<'a>(
1726 Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin,1824 Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin,
1727 _ => outline.origin(),1825 _ => outline.origin(),
1728 };1826 };
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)
1730 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)1828 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)
1731 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)1829 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)
1732 {1830 {
...@@ -1738,7 +1836,7 @@ fn page_primitives<'a>(...@@ -1738,7 +1836,7 @@ fn page_primitives<'a>(
1738 );1836 );
1739 }1837 }
1740 append_outline(1838 append_outline(
1741 (read_only_focus.is_none()1839 (object_focus.is_none()
1742 && outline.id == editor.active_outline().id1840 && outline.id == editor.active_outline().id
1743 && !matches!(preview, Some(PointerFeedback::Resize(_))))1841 && !matches!(preview, Some(PointerFeedback::Resize(_))))
1744 .then_some(editor),1842 .then_some(editor),
...@@ -1751,7 +1849,11 @@ fn page_primitives<'a>(...@@ -1751,7 +1849,11 @@ fn page_primitives<'a>(
1751 )1849 )
1752 };1850 };
1753 if let Some((scene, origin)) = scene {1851 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)?;
1755 }1857 }
1756 for outline in editor1858 for outline in editor
1757 .visible_outlines()1859 .visible_outlines()
...@@ -1759,7 +1861,7 @@ fn page_primitives<'a>(...@@ -1759,7 +1861,7 @@ fn page_primitives<'a>(
1759 {1861 {
1760 paint(outline.id, [0.0; 2], &mut primitives)?;1862 paint(outline.id, [0.0; 2], &mut primitives)?;
1761 }1863 }
1762 if read_only_focus.is_none()1864 if object_focus.is_none()
1763 && let Some(outline) = editor.caret_outline()1865 && let Some(outline) = editor.caret_outline()
1764 {1866 {
1765 append_outline(1867 append_outline(
...@@ -1772,7 +1874,20 @@ fn page_primitives<'a>(...@@ -1772,7 +1874,20 @@ fn page_primitives<'a>(
1772 &mut primitives,1874 &mut primitives,
1773 )?;1875 )?;
1774 }1876 }
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 {
1776 let (scene, offset) = scene.unwrap();1891 let (scene, offset) = scene.unwrap();
1777 let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect();1892 let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect();
1778 let [x0, y0, x1, y1] = [1893 let [x0, y0, x1, y1] = [
...@@ -1797,6 +1912,124 @@ fn page_primitives<'a>(...@@ -1797,6 +1912,124 @@ fn page_primitives<'a>(
1797 Ok(primitives)1912 Ok(primitives)
1798}1913}
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
1800fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) -> Selection {2033fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) -> Selection {
1801 if unit == SelectionUnit::Grapheme {2034 if unit == SelectionUnit::Grapheme {
1802 return Selection {2035 return Selection {
...@@ -2500,6 +2733,33 @@ mod tests {...@@ -2500,6 +2733,33 @@ mod tests {
2500 );2733 );
2501 }2734 }
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
2503 #[test]2763 #[test]
2504 fn grouped_drag_keeps_the_initial_word_when_reversing_direction() {2764 fn grouped_drag_keeps_the_initial_word_when_reversing_direction() {
2505 let selection = |start, end| {2765 let selection = |start, end| {
...@@ -3257,7 +3517,16 @@ mod tests {...@@ -3257,7 +3517,16 @@ mod tests {
3257 .any(|(_, color)| *color == macos::text_colors()[0])3517 .any(|(_, color)| *color == macos::text_colors()[0])
3258 );3518 );
3259 let selected = rectangles(3519 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(),
3261 );3530 );
3262 assert!(3531 assert!(
3263 !selected3532 !selected
...@@ -3267,8 +3536,16 @@ mod tests {...@@ -3267,8 +3536,16 @@ mod tests {
3267 assert_eq!(3536 assert_eq!(
3268 selected,3537 selected,
3269 rectangles(3538 rectangles(
3270 page_primitives(&editor, Some(&scene), None, Some(0), false, scale, 1.0)3539 page_primitives(
3271 .unwrap()3540 &editor,
3541 Some(&scene),
3542 None,
3543 Some(ObjectFocus::ReadOnly(0)),
3544 false,
3545 scale,
3546 1.0
3547 )
3548 .unwrap()
3272 )3549 )
3273 );3550 );
3274 assert_eq!(3551 assert_eq!(
...@@ -3285,7 +3562,16 @@ mod tests {...@@ -3285,7 +3562,16 @@ mod tests {
3285 .any(|(_, color)| *color == macos::text_colors()[1])3562 .any(|(_, color)| *color == macos::text_colors()[1])
3286 );3563 );
3287 let selected = rectangles(3564 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(),
3289 );3575 );
3290 assert!(3576 assert!(
3291 !selected3577 !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...@@ -115,7 +115,7 @@ The host sleeps its caret timer while macOS reports the window occluded and requ
115"target/Snowbound.app/Contents/MacOS/Snowbound" --page PRIVATE_SECTION_COPY EXACT_PAGE_TITLE --substitute-font CARLITO_REGULAR_FILE115"target/Snowbound.app/Contents/MacOS/Snowbound" --page PRIVATE_SECTION_COPY EXACT_PAGE_TITLE --substitute-font CARLITO_REGULAR_FILE
116```116```
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
120`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.120`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...@@ -129,7 +129,7 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA
129129
130Unsupported 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.130Unsupported 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
134Document and layout values are read-only outside the editor; edits update their retained geometry together. `CanvasEditor::from_outlines` remains available for text-only construction.134Document 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