| author | |
| committer | |
| log | 22ec560def0e7769c2cfd79da312576382966e1e |
| tree | 75eeac9fc31d0630dc42582ee08793bffe57647c |
| parent | 1916e882c31374800ac83f9c8bc6a362c71320ca |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
OneNote's 18 pt placement grid runs through the page's margin origin rather than the default
36 pt. On pages like "av: casual night in the trees" (origin 36.75) every drag, move and new
caret landed 0.75 pt off; a 22.5 x 15 pt picture drag now lands where OneNote 2010 puts it.
Assisted-by: claude-opus-5.53 files changed, 34 insertions(+), 12 deletions(-)
crates/canvas/src/editor.rs+5| ... | @@ -1401,6 +1401,11 @@ impl CanvasEditor { | ... | @@ -1401,6 +1401,11 @@ impl CanvasEditor { |
| 1401 | self.active_outline().selection | 1401 | self.active_outline().selection |
| 1402 | } | 1402 | } |
| 1403 | 1403 | ||
| 1404 | /// OneNote's 18 pt placement grid passes through this point. | ||
| 1405 | pub fn margin_origin(&self) -> [f32; 2] { | ||
| 1406 | self.header.margin_origin | ||
| 1407 | } | ||
| 1408 | |||
| 1404 | pub fn resize(&mut self, engine: &mut TextEngine, width: f32) -> Result<(), EditorError> { | 1409 | pub fn resize(&mut self, engine: &mut TextEngine, width: f32) -> Result<(), EditorError> { |
| 1405 | let outline = self.active_outline(); | 1410 | let outline = self.active_outline(); |
| 1406 | if outline.layout.reserved_width.or(outline.layout.max_width) == Some(width) | 1411 | if outline.layout.reserved_width.or(outline.layout.max_width) == Some(width) |
crates/snowbound/src/main.rs+28-11| ... | @@ -372,7 +372,7 @@ impl State { | ... | @@ -372,7 +372,7 @@ impl State { |
| 372 | if self.modifiers.alt_key() { | 372 | if self.modifiers.alt_key() { |
| 373 | position | 373 | position |
| 374 | } else { | 374 | } else { |
| 375 | snap_to_grid(position) | 375 | snap_to_grid(position, self.editor.margin_origin()) |
| 376 | }, | 376 | }, |
| 377 | )) | 377 | )) |
| 378 | } | 378 | } |
| ... | @@ -398,7 +398,7 @@ impl State { | ... | @@ -398,7 +398,7 @@ impl State { |
| 398 | let origin = if self.modifiers.alt_key() { | 398 | let origin = if self.modifiers.alt_key() { |
| 399 | origin | 399 | origin |
| 400 | } else { | 400 | } else { |
| 401 | snap_to_grid(origin) | 401 | snap_to_grid(origin, self.editor.margin_origin()) |
| 402 | }; | 402 | }; |
| 403 | Some((*id, origin, size)) | 403 | Some((*id, origin, size)) |
| 404 | } | 404 | } |
| ... | @@ -1028,7 +1028,7 @@ impl State { | ... | @@ -1028,7 +1028,7 @@ impl State { |
| 1028 | }; | 1028 | }; |
| 1029 | self.editor.place_caret( | 1029 | self.editor.place_caret( |
| 1030 | &mut self.engine, | 1030 | &mut self.engine, |
| 1031 | snap_to_grid(position), | 1031 | snap_to_grid(position, self.editor.margin_origin()), |
| 1032 | DEFAULT_OUTLINE_WIDTH, | 1032 | DEFAULT_OUTLINE_WIDTH, |
| 1033 | )?; | 1033 | )?; |
| 1034 | self.set_object_focus(None); | 1034 | self.set_object_focus(None); |
| ... | @@ -1571,7 +1571,7 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -1571,7 +1571,7 @@ impl ApplicationHandler<UserEvent> for App { |
| 1571 | let position = if state.modifiers.alt_key() { | 1571 | let position = if state.modifiers.alt_key() { |
| 1572 | position | 1572 | position |
| 1573 | } else { | 1573 | } else { |
| 1574 | snap_to_grid(position) | 1574 | snap_to_grid(position, state.editor.margin_origin()) |
| 1575 | }; | 1575 | }; |
| 1576 | state.editor.place_caret( | 1576 | state.editor.place_caret( |
| 1577 | &mut state.engine, | 1577 | &mut state.engine, |
| ... | @@ -2071,9 +2071,9 @@ fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) -> | ... | @@ -2071,9 +2071,9 @@ fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) -> |
| 2071 | } | 2071 | } |
| 2072 | } | 2072 | } |
| 2073 | 2073 | ||
| 2074 | fn snap_to_grid(point: [f32; 2]) -> [f32; 2] { | 2074 | fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] { |
| 2075 | std::array::from_fn(|axis| { | 2075 | std::array::from_fn(|axis| { |
| 2076 | let offset = [0.0, 14.4][axis]; | 2076 | let offset = margin[axis]; |
| 2077 | let cell = (point[axis] - offset) / 18.0; | 2077 | let cell = (point[axis] - offset) / 18.0; |
| 2078 | let nearest = cell.round(); | 2078 | let nearest = cell.round(); |
| 2079 | // Native midpoints remain free; account for the source coordinate's float precision. | 2079 | // Native midpoints remain free; account for the source coordinate's float precision. |
| ... | @@ -2816,6 +2816,17 @@ mod tests { | ... | @@ -2816,6 +2816,17 @@ mod tests { |
| 2816 | ); | 2816 | ); |
| 2817 | } | 2817 | } |
| 2818 | 2818 | ||
| 2819 | const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4]; | ||
| 2820 | |||
| 2821 | #[test] | ||
| 2822 | fn native_grid_follows_the_page_margin_origin() { | ||
| 2823 | // "av: casual night in the trees": margin 36.75; a 22.5 x 15 pt drag lands one cell on. | ||
| 2824 | let margin = [36.75, 14.4]; | ||
| 2825 | let result = snap_to_grid([468.75 + 22.5, 86.4 + 15.0], margin); | ||
| 2826 | assert!((result[0] - 486.75).abs() < 0.0001); | ||
| 2827 | assert!((result[1] - 104.4).abs() < 0.0001); | ||
| 2828 | } | ||
| 2829 | |||
| 2819 | #[test] | 2830 | #[test] |
| 2820 | fn native_grid_matches_drag_offsets_midpoints_and_zoom() { | 2831 | fn native_grid_matches_drag_offsets_midpoints_and_zoom() { |
| 2821 | for (pixels, points) in [ | 2832 | for (pixels, points) in [ |
| ... | @@ -2839,13 +2850,16 @@ mod tests { | ... | @@ -2839,13 +2850,16 @@ mod tests { |
| 2839 | ] { | 2850 | ] { |
| 2840 | let origin = [486.0, 230.40001]; | 2851 | let origin = [486.0, 230.40001]; |
| 2841 | let proposed = origin.map(|v| v + pixels * 72.0 / 96.0); | 2852 | let proposed = origin.map(|v| v + pixels * 72.0 / 96.0); |
| 2842 | let result = snap_to_grid(proposed); | 2853 | let result = snap_to_grid(proposed, DEFAULT_MARGIN); |
| 2843 | for axis in 0..2 { | 2854 | for axis in 0..2 { |
| 2844 | assert!((result[axis] - origin[axis] - points).abs() < 0.00004); | 2855 | assert!((result[axis] - origin[axis] - points).abs() < 0.00004); |
| 2845 | } | 2856 | } |
| 2846 | } | 2857 | } |
| 2847 | for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] { | 2858 | for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] { |
| 2848 | let result = snap_to_grid([491.25 + pixels * 0.75, 235.65 + pixels * 0.75]); | 2859 | let result = snap_to_grid( |
| 2860 | [491.25 + pixels * 0.75, 235.65 + pixels * 0.75], | ||
| 2861 | DEFAULT_MARGIN, | ||
| 2862 | ); | ||
| 2849 | assert!((result[0] - 504.0).abs() < 0.00004); | 2863 | assert!((result[0] - 504.0).abs() < 0.00004); |
| 2850 | assert!((result[1] - 248.4).abs() < 0.00004); | 2864 | assert!((result[1] - 248.4).abs() < 0.00004); |
| 2851 | } | 2865 | } |
| ... | @@ -2858,14 +2872,14 @@ mod tests { | ... | @@ -2858,14 +2872,14 @@ mod tests { |
| 2858 | let origin = [36.0, 90.0]; | 2872 | let origin = [36.0, 90.0]; |
| 2859 | let proposed = | 2873 | let proposed = |
| 2860 | std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr)); | 2874 | std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr)); |
| 2861 | let result = snap_to_grid(proposed); | 2875 | let result = snap_to_grid(proposed, DEFAULT_MARGIN); |
| 2862 | for axis in 0..2 { | 2876 | for axis in 0..2 { |
| 2863 | assert!((result[axis] - expected[axis]).abs() < 0.00004); | 2877 | assert!((result[axis] - expected[axis]).abs() < 0.00004); |
| 2864 | } | 2878 | } |
| 2865 | } | 2879 | } |
| 2866 | } | 2880 | } |
| 2867 | for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] { | 2881 | for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] { |
| 2868 | assert_eq!(snap_to_grid(point), point); | 2882 | assert_eq!(snap_to_grid(point, DEFAULT_MARGIN), point); |
| 2869 | } | 2883 | } |
| 2870 | } | 2884 | } |
| 2871 | 2885 | ||
| ... | @@ -2888,7 +2902,10 @@ mod tests { | ... | @@ -2888,7 +2902,10 @@ mod tests { |
| 2888 | scale: scale * dpr, | 2902 | scale: scale * dpr, |
| 2889 | }; | 2903 | }; |
| 2890 | let point = viewport.document_point([x * dpr, y * dpr]); | 2904 | let point = viewport.document_point([x * dpr, y * dpr]); |
| 2891 | let result = snap_to_grid([point[0], point[1] - 7.0 * dpr / viewport.scale]); | 2905 | let result = snap_to_grid( |
| 2906 | [point[0], point[1] - 7.0 * dpr / viewport.scale], | ||
| 2907 | DEFAULT_MARGIN, | ||
| 2908 | ); | ||
| 2892 | for axis in 0..2 { | 2909 | for axis in 0..2 { |
| 2893 | assert!((result[axis] - expected[axis]).abs() < 0.00004); | 2910 | assert!((result[axis] - expected[axis]).abs() < 0.00004); |
| 2894 | } | 2911 | } |
tools/canvas/README.md+1-1| ... | @@ -68,7 +68,7 @@ Arimo 1.341 and Carlito 1.104 match all 14 recoverable native ASCII wrap control | ... | @@ -68,7 +68,7 @@ Arimo 1.341 and Carlito 1.104 match all 14 recoverable native ASCII wrap control |
| 68 | 68 | ||
| 69 | `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. | 69 | `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. |
| 70 | 70 | ||
| 71 | 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. | 71 | 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. |
| 72 | 72 | ||
| 73 | 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. | 73 | 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. |
| 74 | 74 |