diff --git a/crates/op-editor-core/src/viewport.rs b/crates/op-editor-core/src/viewport.rs index 9d5e68c4e..fbee0eefd 100644 --- a/crates/op-editor-core/src/viewport.rs +++ b/crates/op-editor-core/src/viewport.rs @@ -45,6 +45,17 @@ impl Viewport { /// the content centre to the canvas centre. No-op for an empty /// content rect or a degenerate canvas. pub fn fit_to(&mut self, content: Rect, canvas_w: f32, canvas_h: f32, padding: f32) { + self.fit_to_with_max_zoom(content, canvas_w, canvas_h, padding, Self::MAX_ZOOM); + } + + pub fn fit_to_with_max_zoom( + &mut self, + content: Rect, + canvas_w: f32, + canvas_h: f32, + padding: f32, + max_zoom: f32, + ) { if content.size.x <= 0.0 || content.size.y <= 0.0 || canvas_w <= 0.0 || canvas_h <= 0.0 { return; } @@ -52,7 +63,10 @@ impl Viewport { let avail_h = (canvas_h - 2.0 * padding).max(1.0); let zoom = (avail_w / content.size.x) .min(avail_h / content.size.y) - .clamp(Self::MIN_ZOOM, Self::MAX_ZOOM); + .clamp( + Self::MIN_ZOOM, + max_zoom.clamp(Self::MIN_ZOOM, Self::MAX_ZOOM), + ); let cc_x = content.origin.x + content.size.x / 2.0; let cc_y = content.origin.y + content.size.y / 2.0; self.zoom = zoom; @@ -118,6 +132,21 @@ mod tests { assert!((v.pan_y + 325.0 * v.zoom - 200.0).abs() < 1e-2); } + #[test] + fn fit_to_with_max_zoom_caps_content_fit() { + let mut v = Viewport::IDENTITY; + let content = Rect { + origin: Point2D::new(100.0, 50.0), + size: Point2D::new(200.0, 100.0), + }; + + v.fit_to_with_max_zoom(content, 1000.0, 800.0, 100.0, 2.0); + + assert_eq!(v.zoom, 2.0); + assert!((v.pan_x - 100.0).abs() < 1e-2, "pan_x {}", v.pan_x); + assert!((v.pan_y - 200.0).abs() < 1e-2, "pan_y {}", v.pan_y); + } + #[test] fn fit_to_ignores_empty_content() { let mut v = Viewport::IDENTITY; diff --git a/crates/op-host-desktop/src/main.rs b/crates/op-host-desktop/src/main.rs index cfa8bcee3..a0dd91bbc 100644 --- a/crates/op-host-desktop/src/main.rs +++ b/crates/op-host-desktop/src/main.rs @@ -159,8 +159,12 @@ struct DesktopApp { impl DesktopApp { fn new(initial_file: Option) -> Self { let mut host = WidgetHostNative::new(); + let fit_blank_frame = initial_file.is_none(); // Best-effort prefs restore onto the host's `EditorState`. settings_io::load(host.editor_state_mut()); + if fit_blank_frame { + host.fit_content_to_viewport(INITIAL_VIEWPORT_W, INITIAL_VIEWPORT_H); + } host.mark_editor_state_dirty(); // Baseline for the unsaved-changes prompt — the fresh, // empty document is by definition "saved" (nothing to lose). diff --git a/crates/op-host-desktop/src/main_tests.rs b/crates/op-host-desktop/src/main_tests.rs index fe75a8789..941ec6e62 100644 --- a/crates/op-host-desktop/src/main_tests.rs +++ b/crates/op-host-desktop/src/main_tests.rs @@ -34,3 +34,13 @@ fn cursor_redraw_still_paints_when_layer_hover_changes() { assert!(app.prepare_redraw()); } + +#[test] +fn fresh_app_fits_blank_frame_like_ts_canvas_init() { + let app = DesktopApp::new(None); + let v = app.host.editor_state().viewport; + + assert!((v.zoom - 0.66).abs() < 1e-3, "zoom {}", v.zoom); + assert!((v.pan_x - 64.0).abs() < 1e-2, "pan_x {}", v.pan_x); + assert!((v.pan_y - 166.0).abs() < 1e-2, "pan_y {}", v.pan_y); +} diff --git a/crates/op-host-desktop/src/persistence.rs b/crates/op-host-desktop/src/persistence.rs index b1d0b70e7..d18a50e24 100644 --- a/crates/op-host-desktop/src/persistence.rs +++ b/crates/op-host-desktop/src/persistence.rs @@ -426,7 +426,7 @@ pub fn run_action( match action { FileAction::New => { // Reset the document to a fresh untitled state. - *host.editor_state_mut() = EditorState::new(); + *host.editor_state_mut() = EditorState::starter(); host.mark_editor_state_dirty(); *current_path = None; refresh_title(current_path, window); @@ -702,6 +702,45 @@ mod tests { ); } + #[test] + fn new_file_action_resets_to_starter_frame() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut().doc.children.clear(); + let mut current_path = Some(PathBuf::from("/tmp/old.op")); + + let outcome = run_action( + op_editor_core::editor_ui_state::FileAction::New, + &mut host, + &mut current_path, + None, + ); + + assert_eq!(outcome, ActionOutcome::Saved); + assert!(current_path.is_none()); + assert_eq!(host.editor_state().doc.children.len(), 1); + assert_eq!( + host.editor_state().selection.anchor, + op_editor_core::NodeId::new("n10") + ); + let frame = match &host.editor_state().doc.children[0] { + jian_ops_schema::node::PenNode::Frame(frame) => frame, + other => panic!( + "new file should create the blank starter frame, got {:?}", + other + ), + }; + assert_eq!(frame.base.x, Some(0.0)); + assert_eq!(frame.base.y, Some(0.0)); + assert!(matches!( + frame.container.width, + Some(jian_ops_schema::sizing::SizingBehavior::Number(1200.0)) + )); + assert!(matches!( + frame.container.height, + Some(jian_ops_schema::sizing::SizingBehavior::Number(800.0)) + )); + } + #[test] fn legacy_doc_payload_is_detected() { // Fix 4: a pre-canonical private `DocPayload` JSON (integer diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index c09287f7e..52826ab7e 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -64,6 +64,7 @@ mod settings_dispatch; mod shape_picker_press; mod shortcuts; mod toolbar_hover; +mod viewport_fit; pub use frame_backend::NativeFrameBackend; diff --git a/crates/op-host-native/src/widget_host/geometry.rs b/crates/op-host-native/src/widget_host/geometry.rs index 157e29230..7a8369233 100644 --- a/crates/op-host-native/src/widget_host/geometry.rs +++ b/crates/op-host-native/src/widget_host/geometry.rs @@ -486,7 +486,7 @@ impl WidgetHostNative { let (_l, _t, canvas_w, canvas_h) = self.canvas_region(viewport_w, viewport_h); self.editor_state .viewport - .fit_to(content, canvas_w, canvas_h, 48.0); + .fit_to_with_max_zoom(content, canvas_w, canvas_h, 64.0, 1.0); self.mark_dirty(); } diff --git a/crates/op-host-native/src/widget_host/input_tests.rs b/crates/op-host-native/src/widget_host/input_tests.rs index 1d5f57290..73dfc9d25 100644 --- a/crates/op-host-native/src/widget_host/input_tests.rs +++ b/crates/op-host-native/src/widget_host/input_tests.rs @@ -196,17 +196,9 @@ fn status_bar_search_click_frames_content_in_viewport() { assert!(consumed, "search-icon click must be consumed"); let v = host.editor_state().viewport; - assert!( - (v.zoom - 0.2).abs() > 1e-3, - "zoom should change to frame the content, got {}", - v.zoom - ); - assert!( - v.pan_x > -5000.0 && v.pan_y > -5000.0, - "pan should re-anchor toward the content, got ({}, {})", - v.pan_x, - v.pan_y - ); + assert!((v.zoom - 1.0).abs() < 1e-3, "zoom {}", v.zoom); + assert!((v.pan_x - 230.0).abs() < 1e-2, "pan_x {}", v.pan_x); + assert!((v.pan_y - 180.0).abs() < 1e-2, "pan_y {}", v.pan_y); } #[test] diff --git a/crates/op-host-native/src/widget_host/viewport_fit.rs b/crates/op-host-native/src/widget_host/viewport_fit.rs new file mode 100644 index 000000000..a85646c77 --- /dev/null +++ b/crates/op-host-native/src/widget_host/viewport_fit.rs @@ -0,0 +1,9 @@ +//! Public viewport-fit adapter for desktop runner startup. + +use super::WidgetHostNative; + +impl WidgetHostNative { + pub fn fit_content_to_viewport(&mut self, viewport_w: f32, viewport_h: f32) { + self.zoom_to_fit(viewport_w, viewport_h); + } +} diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index a84191750..82436057c 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -57,7 +57,7 @@ impl WidgetHost { let (_l, _t, canvas_w, canvas_h) = self.canvas_region(viewport_w, viewport_h); self.editor_state .viewport - .fit_to(content, canvas_w, canvas_h, 48.0); + .fit_to_with_max_zoom(content, canvas_w, canvas_h, 64.0, 1.0); self.mark_dirty(); } }