diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 722e7da771ad871ddfd4b268133ca9436c53e459..3eae70374765d7dcdfad58dd81b5a62bccd3068c 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -1401,6 +1401,11 @@ impl CanvasEditor { self.active_outline().selection } + /// OneNote's 18 pt placement grid passes through this point. + pub fn margin_origin(&self) -> [f32; 2] { + self.header.margin_origin + } + pub fn resize(&mut self, engine: &mut TextEngine, width: f32) -> Result<(), EditorError> { let outline = self.active_outline(); if outline.layout.reserved_width.or(outline.layout.max_width) == Some(width) diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 29d4a2eada308668ac875fc241e98cd26bab947e..cbcecbbbe14c743cb531a161080e7ba83558030a 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -372,7 +372,7 @@ impl State { if self.modifiers.alt_key() { position } else { - snap_to_grid(position) + snap_to_grid(position, self.editor.margin_origin()) }, )) } @@ -398,7 +398,7 @@ impl State { let origin = if self.modifiers.alt_key() { origin } else { - snap_to_grid(origin) + snap_to_grid(origin, self.editor.margin_origin()) }; Some((*id, origin, size)) } @@ -1028,7 +1028,7 @@ impl State { }; self.editor.place_caret( &mut self.engine, - snap_to_grid(position), + snap_to_grid(position, self.editor.margin_origin()), DEFAULT_OUTLINE_WIDTH, )?; self.set_object_focus(None); @@ -1571,7 +1571,7 @@ impl ApplicationHandler for App { let position = if state.modifiers.alt_key() { position } else { - snap_to_grid(position) + snap_to_grid(position, state.editor.margin_origin()) }; state.editor.place_caret( &mut state.engine, @@ -2071,9 +2071,9 @@ fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) -> } } -fn snap_to_grid(point: [f32; 2]) -> [f32; 2] { +fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] { std::array::from_fn(|axis| { - let offset = [0.0, 14.4][axis]; + let offset = margin[axis]; let cell = (point[axis] - offset) / 18.0; let nearest = cell.round(); // Native midpoints remain free; account for the source coordinate's float precision. @@ -2816,6 +2816,17 @@ mod tests { ); } + const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4]; + + #[test] + fn native_grid_follows_the_page_margin_origin() { + // "av: casual night in the trees": margin 36.75; a 22.5 x 15 pt drag lands one cell on. + let margin = [36.75, 14.4]; + let result = snap_to_grid([468.75 + 22.5, 86.4 + 15.0], margin); + assert!((result[0] - 486.75).abs() < 0.0001); + assert!((result[1] - 104.4).abs() < 0.0001); + } + #[test] fn native_grid_matches_drag_offsets_midpoints_and_zoom() { for (pixels, points) in [ @@ -2839,13 +2850,16 @@ mod tests { ] { let origin = [486.0, 230.40001]; let proposed = origin.map(|v| v + pixels * 72.0 / 96.0); - let result = snap_to_grid(proposed); + let result = snap_to_grid(proposed, DEFAULT_MARGIN); for axis in 0..2 { assert!((result[axis] - origin[axis] - points).abs() < 0.00004); } } for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] { - let result = snap_to_grid([491.25 + pixels * 0.75, 235.65 + pixels * 0.75]); + let result = snap_to_grid( + [491.25 + pixels * 0.75, 235.65 + pixels * 0.75], + DEFAULT_MARGIN, + ); assert!((result[0] - 504.0).abs() < 0.00004); assert!((result[1] - 248.4).abs() < 0.00004); } @@ -2858,14 +2872,14 @@ mod tests { let origin = [36.0, 90.0]; let proposed = std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr)); - let result = snap_to_grid(proposed); + let result = snap_to_grid(proposed, DEFAULT_MARGIN); for axis in 0..2 { assert!((result[axis] - expected[axis]).abs() < 0.00004); } } } for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] { - assert_eq!(snap_to_grid(point), point); + assert_eq!(snap_to_grid(point, DEFAULT_MARGIN), point); } } @@ -2888,7 +2902,10 @@ mod tests { scale: scale * dpr, }; let point = viewport.document_point([x * dpr, y * dpr]); - let result = snap_to_grid([point[0], point[1] - 7.0 * dpr / viewport.scale]); + let result = snap_to_grid( + [point[0], point[1] - 7.0 * dpr / viewport.scale], + DEFAULT_MARGIN, + ); for axis in 0..2 { assert!((result[axis] - expected[axis]).abs() < 0.00004); } diff --git a/tools/canvas/README.md b/tools/canvas/README.md index a31ce478b39da265b351be6844c73f532fb397a8..7be0fb3f880817c3e9088fd262f7ecc116fd7dde 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -68,7 +68,7 @@ Arimo 1.341 and Carlito 1.104 match all 14 recoverable native ASCII wrap control `CanvasEditor::from_page` owns the working page: text outlines, date fields, image payloads and positions, and retained unsupported objects. GPU support is optional. Text has one visible projection and layout per paragraph, with source UTF-16 selections and affinity at wrap boundaries. Title growth shifts eligible body objects across columns in the same undo transaction; shrinking keeps their positions, while undo and IME cancellation restore them. Background images remain fixed. `CanvasEditor::from_outlines` takes owned outlines and their list/tag definitions, preserving source coordinates, widths, indentation and identities. Rendering and editing share `ParagraphLayout` shaping and outline arrangement, including list-marker height and adjacent spacing. Paragraph spacing contributes only between visible paragraphs; leading and trailing spacing properties remain stored without expanding the outline boundary. Collapsed descendants retain source nodes while navigation, hit-testing and selection geometry use visible paragraphs. LF insertion splits flat paragraphs; boundary Backspace/Delete joins them. Replacements stage every affected paragraph's layout before publishing the edit. Undo restores full affected nodes, including their identities, empty styles and reversed selections; untouched paragraphs retain their layouts. Resizing keeps source selection and history while recomputing geometry at the new width. -The macOS host uses this same editor for typing, pointer/keyboard selection, clipboard text, IME, split/join and undo. Click blank canvas or press Cmd-Shift-N to place a provisional caret with an automatic width capped at 468 pt; the keyboard command places it beside the focused outline or read-only object. The first committed input creates an outline in one undo step; placement alone preserves history and does not dirty the page. Drag an outline's top bar to move it on the default page grid. Hold Option for free pointer placement or dragging; Escape cancels a drag. Use Cmd-Option-arrows (1 pt; Shift makes it 10 pt). Ctrl-Tab and Ctrl-Shift-Tab cycle text outlines and read-only placeholders. A drag previews placement and commits one undo step on release; Escape or loss of window focus cancels the preview. Creation, movement and text edits share one history, while untouched outlines retain their text layouts. Composition can provisionally replace a selection spanning paragraphs; cancellation restores the original paragraphs, selection and redo, while commitment creates one undo step. Vertical navigation retains its preferred column across short or empty paragraphs. Horizontal navigation enters the neighboring paragraph at its visual edge, including when text direction changes. +The macOS host uses this same editor for typing, pointer/keyboard selection, clipboard text, IME, split/join and undo. Click blank canvas or press Cmd-Shift-N to place a provisional caret with an automatic width capped at 468 pt; the keyboard command places it beside the focused outline or read-only object. The first committed input creates an outline in one undo step; placement alone preserves history and does not dirty the page. Drag an outline's top bar to move it on the page's 18 pt grid, which OneNote anchors at the page's margin origin. Hold Option for free pointer placement or dragging; Escape cancels a drag. Use Cmd-Option-arrows (1 pt; Shift makes it 10 pt). Ctrl-Tab and Ctrl-Shift-Tab cycle text outlines and read-only placeholders. A drag previews placement and commits one undo step on release; Escape or loss of window focus cancels the preview. Creation, movement and text edits share one history, while untouched outlines retain their text layouts. Composition can provisionally replace a selection spanning paragraphs; cancellation restores the original paragraphs, selection and redo, while commitment creates one undo step. Vertical navigation retains its preferred column across short or empty paragraphs. Horizontal navigation enters the neighboring paragraph at its visual edge, including when text direction changes. Core tests compare 220 Unicode range/replacement combinations with plain-text replacement, and compare incremental layout with full recomputation through a 64-edit structural history and an 80-step multi-outline history followed by complete undo/redo. They also exercise hidden-field mapping, paragraph-separator selection, grapheme deletion, multiline IME and failed-layout atomicity. Live macOS captures verify Enter, Backspace join, undo/redo, Option-E dead-key commit and the close guard. Native Pinyin input verifies Escape cancellation over single- and cross-paragraph selections: original text, selection and redo survive. Accepting a candidate across paragraphs commits one undo step. Input-event logs distinguish empty preedit cancellation from candidate commitment. Escape commits the spacing acute accent in both this host and a native TextEdit control; that dead-key case is not evidence of composition cancellation.