diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index c59182588..c99c168fa 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -211,6 +211,11 @@ pub struct EditorUiState { /// platform's `HoveredFile` and `HoveredFileCancelled` / drop). Drives /// the full-canvas drop overlay so the user sees a clear drop target. pub file_drop_active: bool, + /// Node the hovering file would fill if released now (image files over a + /// frame / rectangle / … — see `image_drop`). Drives the drop-target ring + /// painted by the same overlay. `None` when the drop would open a document + /// or insert a standalone node instead. Never serialized. + pub file_drop_target: Option, /// Imported Figma documents parsed in Preserve mode already carry /// authored parent-local geometry. The scene builder can use this /// flag to skip the expensive flex/text layout pass. diff --git a/crates/op-editor-core/src/editor_ui_state/defaults.rs b/crates/op-editor-core/src/editor_ui_state/defaults.rs index fa69917ac..22870e3c4 100644 --- a/crates/op-editor-core/src/editor_ui_state/defaults.rs +++ b/crates/op-editor-core/src/editor_ui_state/defaults.rs @@ -49,6 +49,7 @@ impl Default for EditorUiState { figma_import_page_select: Default::default(), figma_import_in_progress: false, file_drop_active: false, + file_drop_target: None, preserve_authored_geometry: false, preview: PreviewState::default(), agent_settings_open: false, diff --git a/crates/op-editor-core/src/host_support.rs b/crates/op-editor-core/src/host_support.rs index 3c69b3af0..fc01cb276 100644 --- a/crates/op-editor-core/src/host_support.rs +++ b/crates/op-editor-core/src/host_support.rs @@ -241,6 +241,30 @@ impl EditorState { ) } + /// Like [`Self::insert_image_node_at_viewport_sized`], but centred on an + /// explicit DOC-space point — the drop point of a dragged image file, so + /// the node lands where the user released it instead of mid-viewport. + pub fn insert_image_node_at_doc_point_sized( + &mut self, + name: &str, + src: &str, + pixel_width: u32, + pixel_height: u32, + centre: (f64, f64), + ) -> Option { + if pixel_width == 0 || pixel_height == 0 { + return None; + } + let scale = (300.0 / f64::from(pixel_width.max(pixel_height))).min(1.0); + self.insert_image_node_with_dimensions( + name, + src, + f64::from(pixel_width) * scale, + f64::from(pixel_height) * scale, + centre, + ) + } + fn insert_image_node_at_viewport_with_dimensions( &mut self, name: &str, @@ -248,14 +272,30 @@ impl EditorState { width: f64, height: f64, ) -> Option { - use jian_ops_schema::node::image::ImageNode; - use jian_ops_schema::node::PenNode; - use jian_ops_schema::sizing::SizingBehavior; let pan_x = self.viewport.pan_x as f64; let pan_y = self.viewport.pan_y as f64; let zoom = self.viewport.zoom.max(0.001) as f64; - let centre_x = -pan_x / zoom; - let centre_y = -pan_y / zoom; + self.insert_image_node_with_dimensions( + name, + src, + width, + height, + (-pan_x / zoom, -pan_y / zoom), + ) + } + + fn insert_image_node_with_dimensions( + &mut self, + name: &str, + src: &str, + width: f64, + height: f64, + centre: (f64, f64), + ) -> Option { + use jian_ops_schema::node::image::ImageNode; + use jian_ops_schema::node::PenNode; + use jian_ops_schema::sizing::SizingBehavior; + let (centre_x, centre_y) = centre; let safe = self.max_node_id().checked_add(1)?; let id = NodeId::new(format!("n{}", safe)); let _next_id = safe.checked_add(1)?; diff --git a/crates/op-editor-core/src/image_drop.rs b/crates/op-editor-core/src/image_drop.rs new file mode 100644 index 000000000..bdb53ebcd --- /dev/null +++ b/crates/op-editor-core/src/image_drop.rs @@ -0,0 +1,88 @@ +//! Dropping an image FILE onto an existing node. +//! +//! The desktop host resolves a drop point to a scene hit path, then asks +//! here which node in that path should receive the bitmap and applies it. +//! Both steps are platform-free so they can be unit-tested without a window. +//! +//! The rule is structural, never name-based: a node accepts an image drop +//! when it paints a filled AREA the bitmap can cover. Text / icons / form +//! widgets carry a `fill` too, but there it colours glyphs or chrome, so a +//! drop over one walks UP to the nearest area ancestor instead — which is +//! what makes "drop onto a placeholder box that contains a hint label and an +//! upload icon" land on the box. + +use crate::walkers::find_node; +use crate::{EditorState, ImageFillMode, NodeId}; +use jian_ops_schema::node::PenNode; + +/// Whether a dropped image can become this node's content. +/// +/// `Image` is included because replacing its `src` is the same gesture from +/// the user's side. `Group` is excluded: it is a structural wrapper with no +/// painted body of its own, so filling it would show nothing. +pub fn node_accepts_image_drop(node: &PenNode) -> bool { + matches!( + node, + PenNode::Frame(_) + | PenNode::Rectangle(_) + | PenNode::Ellipse(_) + | PenNode::Polygon(_) + | PenNode::Path(_) + | PenNode::Image(_) + ) +} + +impl EditorState { + /// Pick the node an image dropped over `hit_path` should fill. + /// + /// `hit_path` is the scene's root-to-deepest hit chain + /// (`LayoutScene::node_path_at_doc_point`). The search runs deepest-first + /// so the innermost box under the cursor wins, and skips locked / + /// unresolvable entries so a drop can still reach an editable ancestor. + pub fn resolve_image_drop_target(&self, hit_path: &[String]) -> Option { + for raw in hit_path.iter().rev() { + let id = NodeId::new(raw.as_str()); + let Some(node) = find_node(self.active_children(), &id) else { + continue; + }; + if node_accepts_image_drop(node) && self.is_editable(&id) { + return Some(id); + } + } + None + } + + /// Apply a dropped image to `target` as ONE undoable step. + /// + /// The fill is written in `Fill` mode — cover: the bitmap scales to the + /// short side and is centre-cropped to the node's box — which is what a + /// drop onto a placeholder frame is asking for. Returns `false` (leaving + /// history untouched) when the target vanished or refuses image fills. + pub fn apply_image_drop( + &mut self, + target: &NodeId, + src: &str, + original_size: Option<[f32; 2]>, + ) -> bool { + let accepts = + find_node(self.active_children(), target).is_some_and(node_accepts_image_drop); + if !accepts || !self.is_editable(target) { + return false; + } + self.commit_history(); + let applied = + self.set_node_fill_image_url(target, src, original_size, Some(ImageFillMode::Fill)); + debug_assert!( + applied, + "a node that accepts image drops must take the fill" + ); + if applied { + self.set_single_selection(target.clone()); + } + applied + } +} + +#[cfg(test)] +#[path = "image_drop_tests.rs"] +mod tests; diff --git a/crates/op-editor-core/src/image_drop_tests.rs b/crates/op-editor-core/src/image_drop_tests.rs new file mode 100644 index 000000000..41f30dddc --- /dev/null +++ b/crates/op-editor-core/src/image_drop_tests.rs @@ -0,0 +1,191 @@ +//! Drop-target resolution + application tests for [`super`]. + +use crate::fills::first_image_fill_summary; +use crate::pen_node_ext::PenNodeExt; +use crate::walkers::find_node; +use crate::{EditorState, ImageFillMode, NodeId}; +use jian_ops_schema::node::PenNode; + +const SRC: &str = "data:image/png;base64,AAAA"; + +fn state_from(src: &str) -> EditorState { + let doc = jian_ops_schema::load_str(src) + .expect("fixture parses") + .value; + let mut state = EditorState::from_document(doc); + state.clear_selection(); + state +} + +/// A placeholder box in a template is a frame wrapping a hint label and an +/// icon wrapper. The pointer lands on the label, but the drop must fill the +/// BOX — resolved structurally (text paints no droppable area), never by name. +fn placeholder_state() -> EditorState { + state_from( + r##"{ + "version":"1.0.0", + "children":[{ + "type":"frame","id":"shot-slot","name":"Screenshot slot", + "x":0,"y":0,"width":400,"height":300, + "children":[ + {"type":"text","id":"hint","content":"Drop a screenshot","x":20,"y":20, + "width":200,"fontSize":14}, + {"type":"frame","id":"icon-wrap","x":20,"y":60,"width":48,"height":48, + "children":[]} + ] + }] + }"##, + ) +} + +fn history_depth(state: &EditorState) -> usize { + state.history.past.len() +} + +#[test] +fn drop_over_a_label_fills_the_nearest_area_ancestor() { + let state = placeholder_state(); + let target = state + .resolve_image_drop_target(&["shot-slot".to_string(), "hint".to_string()]) + .expect("text hit walks up to the frame"); + assert_eq!(target, NodeId::new("shot-slot")); +} + +#[test] +fn drop_over_a_nested_frame_fills_that_frame_not_its_parent() { + let state = placeholder_state(); + let target = state + .resolve_image_drop_target(&["shot-slot".to_string(), "icon-wrap".to_string()]) + .expect("deepest droppable node wins"); + assert_eq!(target, NodeId::new("icon-wrap")); +} + +#[test] +fn drop_over_empty_space_has_no_target() { + let state = placeholder_state(); + assert_eq!(state.resolve_image_drop_target(&[]), None); +} + +#[test] +fn a_locked_box_is_skipped_in_favour_of_an_editable_ancestor() { + let mut state = placeholder_state(); + let node = + crate::walkers::find_node_mut(state.active_children_mut(), &NodeId::new("icon-wrap")) + .expect("inner frame exists"); + node.base_mut().locked = Some(true); + + let target = state + .resolve_image_drop_target(&["shot-slot".to_string(), "icon-wrap".to_string()]) + .expect("locked node yields to its editable parent"); + assert_eq!(target, NodeId::new("shot-slot")); +} + +#[test] +fn applied_drop_writes_a_cover_image_fill_and_selects_the_target() { + let mut state = placeholder_state(); + let target = NodeId::new("shot-slot"); + assert!(state.apply_image_drop(&target, SRC, Some([1200.0, 800.0]))); + + let node = find_node(state.active_children(), &target).expect("target still present"); + let summary = first_image_fill_summary(node).expect("image fill written"); + assert_eq!(summary.image_url.as_deref(), Some(SRC)); + assert_eq!(summary.mode, ImageFillMode::Fill); + assert_eq!(summary.original_size, Some([1200.0, 800.0])); + assert_eq!(state.selection.anchor, target); +} + +/// One gesture must be one undo step — not "fill" plus a stray commit that +/// the user has to press Cmd+Z twice to unwind. +#[test] +fn one_drop_is_one_undo_step() { + let mut state = placeholder_state(); + let before = history_depth(&state); + let target = NodeId::new("shot-slot"); + assert!(state.apply_image_drop(&target, SRC, None)); + assert_eq!(history_depth(&state), before + 1); + + state.undo(); + let node = find_node(state.active_children(), &target).expect("target restored"); + assert!( + first_image_fill_summary(node).is_none(), + "undo must remove the dropped fill" + ); +} + +#[test] +fn dropping_onto_an_image_node_replaces_its_source() { + let mut state = state_from( + r##"{ + "version":"1.0.0", + "children":[{"type":"image","id":"photo","src":"assets/old.png", + "x":0,"y":0,"width":100,"height":100}] + }"##, + ); + let target = NodeId::new("photo"); + assert!(state.apply_image_drop(&target, SRC, None)); + + let PenNode::Image(image) = + find_node(state.active_children(), &target).expect("image still present") + else { + panic!("expected an image node"); + }; + assert_eq!(image.src.as_str(), SRC); +} + +/// Text is the case the structural rule exists for: it HAS a fill list, but +/// that fill paints glyphs, so an image there would be invisible nonsense. +#[test] +fn text_and_missing_nodes_refuse_the_drop_without_touching_history() { + let mut state = placeholder_state(); + let before = history_depth(&state); + assert!(!state.apply_image_drop(&NodeId::new("hint"), SRC, None)); + assert!(!state.apply_image_drop(&NodeId::new("does-not-exist"), SRC, None)); + assert_eq!(history_depth(&state), before); +} + +#[test] +fn a_drop_on_empty_canvas_inserts_the_image_at_the_drop_point() { + let mut state = placeholder_state(); + let id = state + .insert_image_node_at_doc_point_sized("Shot", SRC, 600, 400, (1000.0, 500.0)) + .expect("image node inserted"); + + let PenNode::Image(image) = find_node(state.active_children(), &id).expect("inserted node") + else { + panic!("expected an image node"); + }; + // 600×400 capped to a 300 px long side → 300×200, centred on the point. + assert_eq!(image.base.x, Some(1000.0 - 150.0)); + assert_eq!(image.base.y, Some(500.0 - 100.0)); + assert_eq!(numeric_size(image.width.as_ref()), 300.0); + assert_eq!(numeric_size(image.height.as_ref()), 200.0); +} + +fn numeric_size(value: Option<&jian_ops_schema::sizing::SizingBehavior>) -> f64 { + match value { + Some(jian_ops_schema::sizing::SizingBehavior::Number(n)) => *n, + other => panic!("expected a numeric size, got {other:?}"), + } +} + +/// The drop writes plain canonical schema, so a saved document must load back +/// with the fill intact — no host-only shape the parser drops on the way in. +/// Guards the "it looked right until you reopened the file" failure. +#[test] +fn a_dropped_fill_survives_a_document_roundtrip() { + let mut state = placeholder_state(); + let target = NodeId::new("shot-slot"); + assert!(state.apply_image_drop(&target, SRC, Some([1200.0, 800.0]))); + + let json = serde_json::to_string(&state.doc).expect("serialize document"); + let reloaded = jian_ops_schema::load_str(&json) + .expect("saved document reloads") + .value; + let reloaded = EditorState::from_document(reloaded); + + let node = find_node(reloaded.active_children(), &target).expect("target survived"); + let summary = first_image_fill_summary(node).expect("image fill survived"); + assert_eq!(summary.image_url.as_deref(), Some(SRC)); + assert_eq!(summary.mode, ImageFillMode::Fill); + assert_eq!(summary.original_size, Some([1200.0, 800.0])); +} diff --git a/crates/op-editor-core/src/image_fill_upload.rs b/crates/op-editor-core/src/image_fill_upload.rs index c4290fb9f..34cdcff6a 100644 --- a/crates/op-editor-core/src/image_fill_upload.rs +++ b/crates/op-editor-core/src/image_fill_upload.rs @@ -2,7 +2,7 @@ use crate::fills::node_fills_mut; use crate::walkers::find_node_mut; -use crate::EditorState; +use crate::{EditorState, ImageFillMode, NodeId}; use jian_ops_schema::node::PenNode; use jian_ops_schema::style::{ImageFillBody, ImageOriginalSize, PenFill}; @@ -27,6 +27,26 @@ impl EditorState { original_size: Option<[f32; 2]>, ) -> bool { let sel = self.selection.anchor.clone(); + self.set_node_fill_image_url(&sel, src, original_size, None) + } + + /// Write `src` into `id`'s primary fill as an image. + /// + /// `mode` is written verbatim when given; `None` leaves the field absent, + /// which every renderer resolves to `Fill` (cover) anyway. An `Image` node + /// takes the url on its `src` instead — it has no fill list. + /// + /// Does NOT snapshot history: the two call sites differ (the fill picker + /// bumps the revision, the drag-and-drop path wants one undo step around + /// its whole gesture), so the caller owns that decision. + pub fn set_node_fill_image_url( + &mut self, + id: &NodeId, + src: &str, + original_size: Option<[f32; 2]>, + mode: Option, + ) -> bool { + let sel = id.clone(); if !sel.is_real() || !self.is_editable(&sel) { return false; } @@ -47,7 +67,7 @@ impl EditorState { }; let body = PenFill::Image(ImageFillBody { url: src.into(), - mode: None, + mode: mode.map(ImageFillMode::to_schema), original_size, transform: None, tile_scale: None, @@ -80,7 +100,6 @@ impl EditorState { #[cfg(test)] mod tests { use super::*; - use crate::{ImageFillMode, NodeId}; #[test] fn sized_fill_upload_persists_dimensions_and_exits_stale_crop_edit() { diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index 73471d4c6..42cdecddf 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -81,6 +81,7 @@ pub mod host_variables_commit; pub mod host_variables_transitions; pub mod icon_picker_state; pub mod image_crop; +pub mod image_drop; mod image_fill_upload; pub mod image_node_props; pub mod image_panel_state; diff --git a/crates/op-editor-ui/src/widgets/file_drop_overlay.rs b/crates/op-editor-ui/src/widgets/file_drop_overlay.rs index ce7e3be59..935053a8c 100644 --- a/crates/op-editor-ui/src/widgets/file_drop_overlay.rs +++ b/crates/op-editor-ui/src/widgets/file_drop_overlay.rs @@ -13,11 +13,17 @@ use crate::{Color, Point2D, Rect, RenderBackend, TextLayout}; /// Paint the drop overlay across `canvas_rect` (the editor's canvas /// region — i.e. excluding the rails so the highlight reads as "drop /// onto the canvas"). +/// +/// `target` is the SCREEN rect of the node the file would land in when the +/// drag is an image over a fillable node. Given one, the overlay drops its +/// centred "drop to open" card and rings that node instead: the drop has a +/// specific destination, so pointing at it is the honest feedback. pub fn paint_file_drop_overlay( backend: &mut dyn RenderBackend, theme: &Theme, locale: op_editor_core::Locale, canvas_rect: Rect, + target: Option, ) { let p = theme.primary; // 1. A subtle primary-tinted scrim over the whole canvas. @@ -41,7 +47,25 @@ pub fn paint_file_drop_overlay( }; backend.stroke_round_rect(border, 16.0, theme.primary, 2.5); - // 3. A centred card: download icon + label. + // 3. With a resolved node target, ring it and stop — the card would + // otherwise sit in the middle of the canvas describing a different + // (document-open) outcome than the one about to happen. + if let Some(target) = target { + backend.fill_round_rect( + target, + 6.0, + Color { + r: p.r, + g: p.g, + b: p.b, + a: 0.18, + }, + ); + backend.stroke_round_rect(target, 6.0, theme.primary, 2.5); + return; + } + + // 4. Otherwise a centred card: download icon + label. let label_text = op_i18n::translate(locale, "dialog.dropToOpen"); let label_w = backend.measure_text(label_text, 14.0); let card_w = (label_w + 56.0).max(220.0); diff --git a/crates/op-host-desktop/Cargo.toml b/crates/op-host-desktop/Cargo.toml index 8119d8c48..16be076d9 100644 --- a/crates/op-host-desktop/Cargo.toml +++ b/crates/op-host-desktop/Cargo.toml @@ -275,6 +275,9 @@ objc2-app-kit = { version = "0.2", default-features = false, features = [ "std", "NSApplication", "NSColorSpace", + # `NSEvent::mouseLocation` — the live pointer position during a native + # file drag, which winit's drag events do not carry (`src/drag_cursor.rs`). + "NSEvent", "NSImage", "NSResponder", "NSView", diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index 0e01ab0f5..135da8db1 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -12,8 +12,9 @@ mod scheduling; mod window_events; use crate::{ - a11y, chat_session, cursor_icon, figma_import_session, frame, html_import_session, menu, - persistence, window_state, DesktopApp, DesktopEvent, INITIAL_VIEWPORT_H, INITIAL_VIEWPORT_W, + a11y, chat_session, cursor_icon, figma_import_session, frame, html_import_session, + image_drop_host, menu, persistence, window_state, DesktopApp, DesktopEvent, + INITIAL_VIEWPORT_H, INITIAL_VIEWPORT_W, }; use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; @@ -62,6 +63,16 @@ impl ApplicationHandler for DesktopApp { self.schedule_next_wake(event_loop); } } + // A file drag owns the pointer, so the drop-target ring has to be + // re-probed on each wake rather than driven by cursor events. + if self.refresh_image_drop_hover() { + self.request_redraw(true); + } + // A file drag owns the pointer, so the drop-target ring has to be + // re-probed on each wake rather than driven by cursor events. + if self.refresh_image_drop_hover() { + self.request_redraw(true); + } // Native-menu selections arrive on `muda`'s global channel. // A menu click wakes the event loop, so draining here — at // the top of each loop iteration — picks them up promptly. @@ -448,7 +459,7 @@ impl ApplicationHandler for DesktopApp { WindowEvent::CloseRequested => self.on_close_requested(event_loop), WindowEvent::Resized(size) => self.on_resized(event_loop, size), WindowEvent::Moved(pos) => self.on_moved(pos), - WindowEvent::HoveredFile(_path) => self.on_hovered_file(), + WindowEvent::HoveredFile(path) => self.on_hovered_file(&path), WindowEvent::HoveredFileCancelled => self.on_hovered_file_cancelled(), WindowEvent::DroppedFile(path) => self.on_dropped_file(path), WindowEvent::ScaleFactorChanged { scale_factor, .. } => { diff --git a/crates/op-host-desktop/src/app_handler/scheduling.rs b/crates/op-host-desktop/src/app_handler/scheduling.rs index 534e8916c..949063fa8 100644 --- a/crates/op-host-desktop/src/app_handler/scheduling.rs +++ b/crates/op-host-desktop/src/app_handler/scheduling.rs @@ -74,6 +74,8 @@ impl DesktopApp { || self.current_codegen.is_some() || self.current_design_md.is_some() || !self.sub_agents.is_empty() + // Poll the drag position while an image file hovers the window. + || self.hovered_image_drop { event_loop.set_control_flow(ControlFlow::WaitUntil(self.periodic_wake_instant(33))); } else if self diff --git a/crates/op-host-desktop/src/app_handler/window_events.rs b/crates/op-host-desktop/src/app_handler/window_events.rs index 54bc1f26a..a4be774b7 100644 --- a/crates/op-host-desktop/src/app_handler/window_events.rs +++ b/crates/op-host-desktop/src/app_handler/window_events.rs @@ -3,7 +3,7 @@ //! `app_handler.rs` spine to keep it under the 800-line cap; pure code //! motion. -use crate::{chat_session, figma_import_session, html_import_session, persistence, DesktopApp}; +use crate::{chat_session, figma_import_session, html_import_session, image_drop_host, persistence, DesktopApp}; use std::path::PathBuf; use winit::dpi::{PhysicalPosition, PhysicalSize}; use winit::event_loop::ActiveEventLoop; @@ -81,7 +81,7 @@ impl DesktopApp { } } - pub(super) fn on_hovered_file(&mut self) { + pub(super) fn on_hovered_file(&mut self, path: &std::path::Path) { // A file is being dragged over the window — show the // full-canvas drop overlay so the target is obvious. if !self.host.editor_state().editor_ui.file_drop_active { @@ -89,10 +89,23 @@ impl DesktopApp { self.host.mark_editor_state_dirty(); self.request_redraw(true); } + // An image can land INSIDE a node, so from here on the drag + // position is polled every frame to ring the node under it + // (`new_events`); winit reports no cursor moves during a drag. + if image_drop_host::is_supported_image_drop(path) { + self.hovered_image_drop = true; + if self.refresh_image_drop_hover() { + // Repaint only: the ring lives in `editor_ui`, so + // marking the document dirty here would rebuild the + // whole layout scene on every pointer move. + self.request_redraw(true); + } + } } pub(super) fn on_hovered_file_cancelled(&mut self) { // The drag left the window without dropping — hide it. + self.clear_image_drop_hover(); if self.host.editor_state().editor_ui.file_drop_active { self.host.editor_state_mut().editor_ui.file_drop_active = false; self.host.mark_editor_state_dirty(); @@ -101,10 +114,35 @@ impl DesktopApp { } pub(super) fn on_dropped_file(&mut self, path: PathBuf) { + // Resolve the release position BEFORE tearing down the hover + // state — it is what decides fill-a-node vs insert-a-node. + let drop_point = self + .window + .as_ref() + .and_then(crate::drag_cursor::window_cursor_position) + .or(self.drop_cursor); + self.clear_image_drop_hover(); // Clear the drag overlay now that the drop has landed. self.host.editor_state_mut().editor_ui.file_drop_active = false; self.host.mark_editor_state_dirty(); self.request_redraw(true); + if image_drop_host::is_supported_image_drop(&path) { + let outcome = image_drop_host::apply_image_drop( + &mut self.host, + &path, + drop_point, + self.viewport_width, + self.viewport_height, + ); + if outcome == image_drop_host::ImageDropOutcome::Ignored { + eprintln!( + "openpencil-desktop: dropped image had no effect: {}", + path.display() + ); + } + self.request_redraw(true); + return; + } // Drag-and-drop open. `.op` / `.pen` documents route // through the canonical loader; `.fig` Figma exports // route through the background Figma import worker diff --git a/crates/op-host-desktop/src/app_state.rs b/crates/op-host-desktop/src/app_state.rs index dde65dfd4..5b49e495e 100644 --- a/crates/op-host-desktop/src/app_state.rs +++ b/crates/op-host-desktop/src/app_state.rs @@ -126,6 +126,8 @@ impl DesktopApp { provider_reconnect_queue: Vec::new(), remembered_connections: [false; 6], last_seen_provider_phase: Default::default(), + hovered_image_drop: false, + drop_cursor: None, last_saved_pencil_cursor: None, acp_agent_connect_job: None, initial_file, diff --git a/crates/op-host-desktop/src/drag_cursor.rs b/crates/op-host-desktop/src/drag_cursor.rs new file mode 100644 index 000000000..71bae2f1f --- /dev/null +++ b/crates/op-host-desktop/src/drag_cursor.rs @@ -0,0 +1,50 @@ +//! Where the pointer is DURING a native file-drag. +//! +//! winit's drag events (`HoveredFile` / `DroppedFile`) carry a path and +//! nothing else, and the platform suppresses the normal `CursorMoved` stream +//! while a drag session owns the pointer — so the host's cached cursor is the +//! position from *before* the drag started. Dropping a file "onto a node" +//! needs the live position, which has to come from the window system. +//! +//! macOS reads it from AppKit (`NSEvent.mouseLocation` is current regardless +//! of the window's event stream). Other platforms have no equivalent hook +//! wired yet and report `None`; their callers degrade to the position-free +//! behaviour (open documents, insert at the viewport centre). + +/// Live pointer position in the window's LOGICAL, top-left-origin coordinate +/// space — the same space `WindowEvent::CursorMoved` reports, so it can be +/// fed straight into the host's hit-tests. +#[cfg(target_os = "macos")] +pub fn window_cursor_position(window: &winit::window::Window) -> Option<(f32, f32)> { + use objc2_app_kit::{NSEvent, NSView}; + use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + let handle = window.window_handle().ok()?; + let RawWindowHandle::AppKit(handle) = handle.as_raw() else { + return None; + }; + // SAFETY: raw-window-handle's `ns_view` is the live NSView owned by + // `window`. This runs synchronously on winit's main thread while handling + // an event for that window, and the borrow does not outlive the call. + let ns_view = unsafe { handle.ns_view.cast::().as_ref() }; + let ns_window = ns_view.window()?; + + // SAFETY: `mouseLocation` is a class method with no receiver state; the + // remaining conversions are main-thread AppKit geometry calls. + let screen_point = unsafe { NSEvent::mouseLocation() }; + let window_point = ns_window.convertPointFromScreen(screen_point); + let view_point = ns_view.convertPoint_fromView(window_point, None); + // AppKit's default view origin is bottom-left; winit reports top-left. + let y = if ns_view.isFlipped() { + view_point.y + } else { + ns_view.bounds().size.height - view_point.y + }; + Some((view_point.x as f32, y as f32)) +} + +/// No live drag-position hook on this platform — see the module docs. +#[cfg(not(target_os = "macos"))] +pub fn window_cursor_position(_window: &winit::window::Window) -> Option<(f32, f32)> { + None +} diff --git a/crates/op-host-desktop/src/image_drop_host.rs b/crates/op-host-desktop/src/image_drop_host.rs new file mode 100644 index 000000000..0f4027b58 --- /dev/null +++ b/crates/op-host-desktop/src/image_drop_host.rs @@ -0,0 +1,154 @@ +//! Dragging an image FILE onto the canvas. +//! +//! Two outcomes from one gesture, decided by what is under the pointer when +//! the file is released: +//! +//! - over a frame / rectangle / … → the image becomes that node's fill +//! (cover), so a template's placeholder box is filled in one motion; +//! - over bare canvas → the image is inserted as a node at the drop point. +//! +//! The decision is structural (`op_editor_core::image_drop`), never based on +//! node names. Reading + embedding reuses the shared import path in +//! [`crate::persistence_image`], so a drop and a File ▸ Import produce +//! byte-identical `data:` URLs (downscale included). +//! +//! Position comes from [`crate::drag_cursor`], which is macOS-only today; on +//! other platforms every drop degrades to the viewport-centre insert. + +use crate::persistence_image::read_as_data_url; +use crate::DesktopApp; +use op_editor_core::NodeId; +use op_host_native::widget_host::WidgetHostNative; +use std::path::Path; + +/// Raster formats a canvas drop accepts. +/// +/// SVG is deliberately absent: importing one produces editable vector nodes, +/// which is a different gesture from "become this box's picture" — a dropped +/// `.svg` keeps falling through to the existing routing. +const DROP_IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif"]; + +/// Whether a dropped path is a raster image this module handles. +pub fn is_supported_image_drop(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(str::to_ascii_lowercase) + .is_some_and(|ext| DROP_IMAGE_EXTENSIONS.contains(&ext.as_str())) +} + +/// What a drop did, for logging and redraw decisions. +#[derive(Debug, PartialEq, Eq)] +pub enum ImageDropOutcome { + /// The image became `NodeId`'s fill. + Filled(NodeId), + /// The image was inserted as a new node. + Inserted(NodeId), + /// Nothing happened (unreadable file, or the document refused it). + Ignored, +} + +/// Apply a dropped image file. +/// +/// `point` is the LOGICAL window position of the drop, when the platform can +/// report one. `None` — or a point outside the canvas — inserts at the +/// viewport centre, which is where the toolbar import already puts it. +pub fn apply_image_drop( + host: &mut WidgetHostNative, + path: &Path, + point: Option<(f32, f32)>, + viewport_w: f32, + viewport_h: f32, +) -> ImageDropOutcome { + let embedded = match read_as_data_url(path) { + Ok(embedded) => embedded, + Err(e) => { + eprintln!("[image-drop] {}: {e}", path.display()); + return ImageDropOutcome::Ignored; + } + }; + let name = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("Image") + .to_string(); + + if let Some((x, y)) = point { + if let Some(target) = host.image_drop_target_at(x, y, viewport_w, viewport_h) { + if host.apply_image_drop(&target, &embedded.url, embedded.original_size) { + return ImageDropOutcome::Filled(target); + } + return ImageDropOutcome::Ignored; + } + if let Some(centre) = host.canvas_doc_point(x, y, viewport_w, viewport_h) { + return match host.insert_dropped_image( + &name, + &embedded.url, + pixel_size(embedded.original_size), + centre, + ) { + Some(id) => ImageDropOutcome::Inserted(id), + None => ImageDropOutcome::Ignored, + }; + } + } + + let inserted = match pixel_size(embedded.original_size) { + Some((width, height)) => host.editor_state_mut().insert_image_node_at_viewport_sized( + &name, + &embedded.url, + width, + height, + ), + None => host + .editor_state_mut() + .insert_image_node_at_viewport(&name, &embedded.url), + }; + host.mark_editor_state_dirty(); + match inserted { + Some(id) => ImageDropOutcome::Inserted(id), + None => ImageDropOutcome::Ignored, + } +} + +/// Encoded bitmap dimensions as whole pixels, when they were decodable. +fn pixel_size(original_size: Option<[f32; 2]>) -> Option<(u32, u32)> { + let [width, height] = original_size?; + (width >= 1.0 && height >= 1.0).then_some((width as u32, height as u32)) +} + +impl DesktopApp { + /// Track the pointer while an image file hovers the window and highlight + /// the node it would fill. + /// + /// winit reports `HoveredFile` once, without a position, and the platform + /// swallows cursor moves for the duration of the drag — so the position is + /// polled from the window system instead (see [`crate::drag_cursor`]). + /// Returns `true` when the highlight changed. + pub(crate) fn refresh_image_drop_hover(&mut self) -> bool { + if !self.hovered_image_drop { + return false; + } + let Some(window) = self.window.as_ref() else { + return false; + }; + let point = crate::drag_cursor::window_cursor_position(window); + self.drop_cursor = point; + let target = point.and_then(|(x, y)| { + self.host + .image_drop_target_at(x, y, self.viewport_width, self.viewport_height) + }); + self.host.set_file_drop_target(target) + } + + /// Clear every drag-hover trace. Called on drop and on drag-cancel so a + /// stale ring can never outlive the gesture. + pub(crate) fn clear_image_drop_hover(&mut self) { + self.hovered_image_drop = false; + self.drop_cursor = None; + self.host.set_file_drop_target(None); + } +} + +#[cfg(test)] +#[path = "image_drop_host_tests.rs"] +mod tests; diff --git a/crates/op-host-desktop/src/image_drop_host_tests.rs b/crates/op-host-desktop/src/image_drop_host_tests.rs new file mode 100644 index 000000000..ec1dc842d --- /dev/null +++ b/crates/op-host-desktop/src/image_drop_host_tests.rs @@ -0,0 +1,162 @@ +//! Drop routing tests: which arm of the gesture runs, and what lands in the +//! document. The winit event plumbing above this layer is a thin shell and is +//! covered by the manual checklist instead. + +use super::{apply_image_drop, is_supported_image_drop, ImageDropOutcome}; +use op_editor_core::fills::first_image_fill_summary; +use op_editor_core::walkers::find_node; +use op_editor_core::{ImageFillMode, NodeId}; +use op_host_native::widget_host::WidgetHostNative; +use std::path::{Path, PathBuf}; + +const VIEWPORT_W: f32 = 1440.0; +const VIEWPORT_H: f32 = 900.0; + +const PLACEHOLDER: &str = r#"{"version":"1.0.0","children":[ + {"type":"frame","id":"slot","name":"Screenshot slot","x":400,"y":200, + "width":400,"height":300,"children":[ + {"type":"text","id":"hint","content":"Drop a screenshot","x":20,"y":40, + "width":200,"fontSize":16} + ]} +]}"#; + +fn seed() -> WidgetHostNative { + let mut host = WidgetHostNative::new(); + let doc = jian_ops_schema::load_str(PLACEHOLDER) + .expect("fixture JSON parses") + .value; + *host.editor_state_mut() = op_editor_core::EditorState::from_document(doc); + host.mark_editor_state_dirty(); + host +} + +/// Screen rect of the placeholder frame, resolved through the host so the +/// test never has to reimplement the canvas-origin math. +fn slot_rect(host: &mut WidgetHostNative) -> op_editor_ui::Rect { + let _ = host.layout_scene(); + host.node_screen_rect(&NodeId::new("slot"), VIEWPORT_W, VIEWPORT_H) + .expect("placeholder frame is in the scene") +} + +/// A point inside the placeholder. +fn point_on_slot(host: &mut WidgetHostNative) -> (f32, f32) { + let rect = slot_rect(host); + ( + rect.origin.x + rect.size.x / 2.0, + rect.origin.y + rect.size.y / 2.0, + ) +} + +/// A point on bare canvas: left of the placeholder, clear of the floating +/// toolbar column and of the bottom-centred chat panel. +fn point_off_slot(host: &mut WidgetHostNative) -> (f32, f32) { + let rect = slot_rect(host); + (rect.origin.x - 120.0, rect.origin.y + 50.0) +} + +/// A real 7×5 PNG on disk — the drop path decodes it for the fill's +/// `originalSize`, so a fake byte blob would not exercise that. +fn write_png(tag: &str) -> PathBuf { + let mut surface = skia_safe::surfaces::raster_n32_premul((7, 5)).expect("surface"); + surface.canvas().clear(skia_safe::Color::BLUE); + let png = surface + .image_snapshot() + .encode(None, skia_safe::EncodedImageFormat::PNG, 100) + .expect("encode png"); + let path = std::env::temp_dir().join(format!("op-image-drop-{tag}-{}.png", std::process::id())); + std::fs::write(&path, png.as_bytes()).expect("write png"); + path +} + +#[test] +fn only_raster_image_extensions_take_the_drop_path() { + for name in ["shot.png", "SHOT.PNG", "a.jpg", "a.jpeg", "a.webp", "a.gif"] { + assert!(is_supported_image_drop(Path::new(name)), "{name}"); + } + for name in [ + "design.op", + "design.pen", + "export.fig", + "page.html", + "logo.svg", + ] { + assert!(!is_supported_image_drop(Path::new(name)), "{name}"); + } +} + +#[test] +fn a_drop_over_a_placeholder_fills_it_with_the_decoded_bitmap() { + let mut host = seed(); + let path = write_png("fill"); + let point = point_on_slot(&mut host); + + let outcome = apply_image_drop(&mut host, &path, Some(point), VIEWPORT_W, VIEWPORT_H); + let _ = std::fs::remove_file(&path); + + assert_eq!(outcome, ImageDropOutcome::Filled(NodeId::new("slot"))); + let summary = find_node(host.editor_state().active_children(), &NodeId::new("slot")) + .and_then(first_image_fill_summary) + .expect("image fill written"); + assert!(summary + .image_url + .as_deref() + .is_some_and(|url| url.starts_with("data:image/png;base64,"))); + assert_eq!(summary.mode, ImageFillMode::Fill); + assert_eq!(summary.original_size, Some([7.0, 5.0])); +} + +#[test] +fn a_drop_over_bare_canvas_inserts_a_node_instead() { + let mut host = seed(); + let path = write_png("insert"); + let point = point_off_slot(&mut host); + let (doc_x, doc_y) = host + .canvas_doc_point(point.0, point.1, VIEWPORT_W, VIEWPORT_H) + .expect("the fallback point is over the canvas"); + + let outcome = apply_image_drop(&mut host, &path, Some(point), VIEWPORT_W, VIEWPORT_H); + let _ = std::fs::remove_file(&path); + + let ImageDropOutcome::Inserted(id) = outcome else { + panic!("expected an insert, got {outcome:?}"); + }; + let node = + find_node(host.editor_state().active_children(), &id).expect("inserted node is in the doc"); + let jian_ops_schema::node::PenNode::Image(image) = node else { + panic!("expected an image node"); + }; + // 7×5 is under the 300 px cap, so it keeps its natural size, centred. + assert_eq!(image.base.x, Some(doc_x - 3.5)); + assert_eq!(image.base.y, Some(doc_y - 2.5)); +} + +/// Platforms without a live drag position (everything but macOS today) must +/// still accept the file — they just cannot aim it. +#[test] +fn a_drop_without_a_position_falls_back_to_the_viewport_centre() { + let mut host = seed(); + let path = write_png("nopoint"); + + let outcome = apply_image_drop(&mut host, &path, None, VIEWPORT_W, VIEWPORT_H); + let _ = std::fs::remove_file(&path); + + assert!(matches!(outcome, ImageDropOutcome::Inserted(_))); + assert!( + find_node(host.editor_state().active_children(), &NodeId::new("slot")) + .and_then(first_image_fill_summary) + .is_none(), + "a position-less drop must not guess a fill target" + ); +} + +#[test] +fn an_unreadable_file_changes_nothing() { + let mut host = seed(); + let before = host.editor_state().active_children().len(); + let missing = std::env::temp_dir().join("op-image-drop-does-not-exist.png"); + + let outcome = apply_image_drop(&mut host, &missing, None, VIEWPORT_W, VIEWPORT_H); + + assert_eq!(outcome, ImageDropOutcome::Ignored); + assert_eq!(host.editor_state().active_children().len(), before); +} diff --git a/crates/op-host-desktop/src/main.rs b/crates/op-host-desktop/src/main.rs index feaea51ec..7417715eb 100644 --- a/crates/op-host-desktop/src/main.rs +++ b/crates/op-host-desktop/src/main.rs @@ -32,6 +32,7 @@ mod design_loop_indicator; mod design_md_error; mod design_md_host; mod design_session; +mod drag_cursor; mod figma_import_session; mod font_import_host; mod fonts; @@ -45,6 +46,7 @@ mod git_ssh_host; mod heap_pressure; mod html_import_error; mod html_import_session; +mod image_drop_host; mod iconify_host; mod image_decode_host; mod image_downscale; @@ -290,6 +292,13 @@ struct DesktopApp { provider_connect_job: Option, /// Startup reconnect replay queue (see `agent_connect_store`). provider_reconnect_queue: Vec, + /// True while a raster image file is being dragged over the window. + /// Drives the per-frame drop-target probe — the platform gives no + /// cursor stream during a drag, so the position has to be polled. + hovered_image_drop: bool, + /// Last polled drag position (logical, top-left origin). Used as the drop + /// point when the release itself cannot be re-probed. + drop_cursor: Option<(f32, f32)>, /// Last persisted pencil-cursor style (see `ui_prefs`). last_saved_pencil_cursor: Option, /// Providers the store remembers as LAST KNOWN GOOD — seeded from diff --git a/crates/op-host-desktop/src/persistence_image.rs b/crates/op-host-desktop/src/persistence_image.rs index 083f62842..ce30b3b5e 100644 --- a/crates/op-host-desktop/src/persistence_image.rs +++ b/crates/op-host-desktop/src/persistence_image.rs @@ -24,9 +24,9 @@ use std::path::Path; /// File extensions the import dialog accepts. const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg"]; -struct EmbeddedImage { - url: String, - original_size: Option<[f32; 2]>, +pub(crate) struct EmbeddedImage { + pub(crate) url: String, + pub(crate) original_size: Option<[f32; 2]>, } /// Pop a file dialog scoped to image / SVG extensions, returning the @@ -47,7 +47,7 @@ fn pick_image_path(host: &WidgetHostNative) -> Option { /// files get the `image/svg+xml` MIME, everything else picks from a /// small extension table — falling back to `application/octet-stream` /// so an unknown extension still round-trips. -fn read_as_data_url(path: &Path) -> std::io::Result { +pub(crate) fn read_as_data_url(path: &Path) -> std::io::Result { let bytes = std::fs::read(path)?; // Shrink an oversized raster source before it lands in the document // (a multi-MB `src` lags every later scene rebuild + canvas decode). diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index 419cfc63d..c5c706d89 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -114,6 +114,7 @@ mod icon_picker_press_tests; mod image_crop_drag; #[cfg(test)] mod image_crop_drag_tests; +mod image_drop_target; mod image_panel_dispatch; #[cfg(test)] mod image_panel_overlay_tests; diff --git a/crates/op-host-native/src/widget_host/image_drop_target.rs b/crates/op-host-native/src/widget_host/image_drop_target.rs new file mode 100644 index 000000000..eb8d64e65 --- /dev/null +++ b/crates/op-host-native/src/widget_host/image_drop_target.rs @@ -0,0 +1,136 @@ +//! Canvas-side resolution for image files dragged onto the editor. +//! +//! The platform layer knows only "a file is over the window at (x, y)". These +//! helpers turn that into the same answers the pointer paths already produce: +//! the doc point under the cursor, the node that would take the image, and the +//! screen rect to highlight while the file hovers. +//! +//! Every coordinate derivation goes through `canvas_region`, per the host's +//! coordinate invariant — the canvas origin moves with the sidebar. + +use super::WidgetHostNative; +use op_editor_core::NodeId; +use op_editor_ui::{Point2D, Rect}; + +impl WidgetHostNative { + /// DOC-space point under a LOGICAL window point, or `None` when that + /// point is not over the canvas (rails, panels, overlays). + pub fn canvas_doc_point( + &self, + x: f32, + y: f32, + viewport_w: f32, + viewport_h: f32, + ) -> Option<(f64, f64)> { + if !self.over_canvas(x, y, viewport_w, viewport_h) { + return None; + } + let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_w, viewport_h); + let doc = self + .editor_state + .viewport + .to_document(Point2D::new(x - cx0, y - cy0)); + Some((doc.x as f64, doc.y as f64)) + } + + /// Node an image file dropped at this LOGICAL window point would fill. + /// + /// `None` when the point is off-canvas, over dead space, or the editor is + /// previewing (a running app is not an editing surface). + pub fn image_drop_target_at( + &mut self, + x: f32, + y: f32, + viewport_w: f32, + viewport_h: f32, + ) -> Option { + if self.editor_state.editor_ui.preview.mode { + return None; + } + if !self.over_canvas(x, y, viewport_w, viewport_h) { + return None; + } + // Resolve against the CURRENT tree: a drag can arrive right after an + // edit, before the next paint would have rebuilt the scene. + self.refresh_layout_scene(); + let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_w, viewport_h); + let doc_point = self + .editor_state + .viewport + .to_document(Point2D::new(x - cx0, y - cy0)); + let path = self + .layout_scene + .node_path_at_doc_point(doc_point, self.editor_state.viewport.zoom)?; + self.editor_state.resolve_image_drop_target(&path) + } + + /// SCREEN rect of a node in the LAST RESOLVED scene — the drop-target + /// ring's geometry, read from the same tree the frame is painting (like + /// `cursor_over_node`, this does not force a rebuild). `None` when the id + /// is not in that scene. + pub fn node_screen_rect(&self, id: &NodeId, viewport_w: f32, viewport_h: f32) -> Option { + let node = self.layout_scene.active_page()?.find(id.as_str())?; + let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_w, viewport_h); + let vp = &self.editor_state.viewport; + Some(Rect { + origin: Point2D::new( + cx0 + vp.pan_x + node.bounds.origin.x * vp.zoom, + cy0 + vp.pan_y + node.bounds.origin.y * vp.zoom, + ), + size: Point2D::new(node.bounds.size.x * vp.zoom, node.bounds.size.y * vp.zoom), + }) + } + + /// Record which node a hovering image file would fill. Returns `true` + /// when the highlight changed and the frame needs a repaint. + pub fn set_file_drop_target(&mut self, target: Option) -> bool { + if self.editor_state.editor_ui.file_drop_target == target { + return false; + } + self.editor_state.editor_ui.file_drop_target = target; + true + } + + /// Apply a dropped image to `target` as one undoable step, then mark the + /// document dirty so the scene rebuilds and the save state updates. + pub fn apply_image_drop( + &mut self, + target: &NodeId, + src: &str, + original_size: Option<[f32; 2]>, + ) -> bool { + if !self + .editor_state + .apply_image_drop(target, src, original_size) + { + return false; + } + self.mark_editor_state_dirty(); + true + } + + /// Insert a dropped image as a standalone node centred on `centre` + /// (DOC space), the empty-canvas arm of the same gesture. + pub fn insert_dropped_image( + &mut self, + name: &str, + src: &str, + pixel_size: Option<(u32, u32)>, + centre: (f64, f64), + ) -> Option { + let (pixel_width, pixel_height) = pixel_size.unwrap_or((300, 200)); + let id = self.editor_state.insert_image_node_at_doc_point_sized( + name, + src, + pixel_width, + pixel_height, + centre, + )?; + self.mark_editor_state_dirty(); + Some(id) + } +} + +#[cfg(test)] +#[path = "image_drop_target_tests.rs"] +mod tests; diff --git a/crates/op-host-native/src/widget_host/image_drop_target_tests.rs b/crates/op-host-native/src/widget_host/image_drop_target_tests.rs new file mode 100644 index 000000000..498db4fd4 --- /dev/null +++ b/crates/op-host-native/src/widget_host/image_drop_target_tests.rs @@ -0,0 +1,151 @@ +//! Drop-point → drop-target resolution tests. +//! +//! Geometry discipline mirrors the other canvas-press suites: viewport +//! 1440×900 and fixtures at doc x ≥ 400 so every probe lands clear of the +//! floating toolbar and chat panel. + +use super::WidgetHostNative; +use op_editor_core::NodeId; + +const VIEWPORT_W: f32 = 1440.0; +const VIEWPORT_H: f32 = 900.0; + +/// A screenshot placeholder: a frame with a hint label and an icon box, +/// exactly the shape the template ships. +const PLACEHOLDER: &str = r#"{"version":"1.0.0","children":[ + {"type":"frame","id":"slot","name":"Screenshot slot","x":400,"y":200, + "width":400,"height":300,"children":[ + {"type":"text","id":"hint","content":"Drop a screenshot","x":20,"y":40, + "width":200,"fontSize":16} + ]} +]}"#; + +fn seed(json: &str) -> WidgetHostNative { + let mut host = WidgetHostNative::new(); + let doc = jian_ops_schema::load_str(json) + .expect("fixture JSON parses") + .value; + *host.editor_state_mut() = op_editor_core::EditorState::from_document(doc); + host.mark_paint_dirty_for_test(); + host +} + +/// Screen point for a doc point (zoom 1, pan 0 in a fresh host). +fn screen_at(host: &WidgetHostNative, doc_x: f32, doc_y: f32) -> (f32, f32) { + let (cx0, cy0, _cw, _ch) = host.canvas_region(VIEWPORT_W, VIEWPORT_H); + (cx0 + doc_x, cy0 + doc_y) +} + +#[test] +fn a_drop_over_the_hint_label_targets_the_placeholder_frame() { + let mut host = seed(PLACEHOLDER); + let (x, y) = screen_at(&host, 430.0, 250.0); + assert_eq!( + host.image_drop_target_at(x, y, VIEWPORT_W, VIEWPORT_H), + Some(NodeId::new("slot")) + ); +} + +#[test] +fn a_drop_over_bare_canvas_has_no_target_but_still_maps_to_a_doc_point() { + let mut host = seed(PLACEHOLDER); + let (x, y) = screen_at(&host, 1000.0, 700.0); + assert_eq!( + host.image_drop_target_at(x, y, VIEWPORT_W, VIEWPORT_H), + None + ); + let point = host + .canvas_doc_point(x, y, VIEWPORT_W, VIEWPORT_H) + .expect("bare canvas still resolves a doc point"); + assert_eq!(point, (1000.0, 700.0)); +} + +/// The canvas origin moves with the sidebar, so a point over the rail is not +/// a canvas point at all — dropping there must not fill anything. +#[test] +fn a_drop_over_the_left_rail_is_not_a_canvas_drop() { + let mut host = seed(PLACEHOLDER); + host.editor_state_mut().editor_ui.sidebar_open = true; + host.mark_paint_dirty_for_test(); + assert_eq!( + host.image_drop_target_at(10.0, 400.0, VIEWPORT_W, VIEWPORT_H), + None + ); + assert_eq!( + host.canvas_doc_point(10.0, 400.0, VIEWPORT_W, VIEWPORT_H), + None + ); +} + +/// Preview mode runs the design as an app; a file dropped on it must not +/// silently edit the document behind the running screen. +#[test] +fn preview_mode_refuses_drop_targets() { + let mut host = seed(PLACEHOLDER); + host.editor_state_mut().editor_ui.preview_mode = true; + let (x, y) = screen_at(&host, 430.0, 250.0); + assert_eq!( + host.image_drop_target_at(x, y, VIEWPORT_W, VIEWPORT_H), + None + ); +} + +#[test] +fn the_target_ring_rect_follows_pan_and_zoom() { + let mut host = seed(PLACEHOLDER); + host.editor_state_mut().viewport.zoom = 2.0; + host.editor_state_mut().viewport.pan_x = 30.0; + host.editor_state_mut().viewport.pan_y = -40.0; + host.mark_paint_dirty_for_test(); + let _ = host.layout_scene(); + + let (cx0, cy0, _cw, _ch) = host.canvas_region(VIEWPORT_W, VIEWPORT_H); + let rect = host + .node_screen_rect(&NodeId::new("slot"), VIEWPORT_W, VIEWPORT_H) + .expect("frame is in the scene"); + assert_eq!(rect.origin.x, cx0 + 30.0 + 400.0 * 2.0); + assert_eq!(rect.origin.y, cy0 - 40.0 + 200.0 * 2.0); + assert_eq!(rect.size.x, 800.0); + assert_eq!(rect.size.y, 600.0); +} + +#[test] +fn applying_a_drop_dirties_the_host_and_can_be_undone_in_one_step() { + let mut host = seed(PLACEHOLDER); + let target = NodeId::new("slot"); + assert!(host.apply_image_drop(&target, "data:image/png;base64,AAAA", Some([800.0, 600.0]))); + + let filled = op_editor_core::walkers::find_node(host.editor_state().active_children(), &target) + .and_then(op_editor_core::fills::first_image_fill_summary) + .expect("image fill written"); + assert_eq!( + filled.image_url.as_deref(), + Some("data:image/png;base64,AAAA") + ); + + host.editor_state_mut().undo(); + let after = op_editor_core::walkers::find_node(host.editor_state().active_children(), &target) + .and_then(op_editor_core::fills::first_image_fill_summary); + assert!(after.is_none(), "one drop is one undo step"); +} + +#[test] +fn a_bare_canvas_drop_inserts_the_image_centred_on_the_drop_point() { + let mut host = seed(PLACEHOLDER); + let id = host + .insert_dropped_image( + "Shot", + "data:image/png;base64,AAAA", + Some((600, 400)), + (1000.0, 700.0), + ) + .expect("image node inserted"); + + let node = op_editor_core::walkers::find_node(host.editor_state().active_children(), &id) + .expect("inserted node"); + let jian_ops_schema::node::PenNode::Image(image) = node else { + panic!("expected an image node"); + }; + assert_eq!(image.base.x, Some(850.0)); + assert_eq!(image.base.y, Some(600.0)); +} diff --git a/crates/op-host-native/src/widget_host/paint.rs b/crates/op-host-native/src/widget_host/paint.rs index 14f3162f3..aefb02eb9 100644 --- a/crates/op-host-native/src/widget_host/paint.rs +++ b/crates/op-host-native/src/widget_host/paint.rs @@ -719,11 +719,18 @@ impl WidgetHostNative { origin: Point2D::new(drop_left, TOP_BAR_HEIGHT), size: Point2D::new(drop_w, drop_h), }; + let target = self + .editor_state + .editor_ui + .file_drop_target + .clone() + .and_then(|id| self.node_screen_rect(&id, viewport_width, viewport_height)); op_editor_ui::widgets::file_drop_overlay::paint_file_drop_overlay( &mut *frame, &self.theme, self.editor_state.editor_ui.locale, drop_rect, + target, ); } diff --git a/crates/op-host-web/src/widget_host/paint.rs b/crates/op-host-web/src/widget_host/paint.rs index fa2057ffb..e876270e9 100644 --- a/crates/op-host-web/src/widget_host/paint.rs +++ b/crates/op-host-web/src/widget_host/paint.rs @@ -554,6 +554,9 @@ impl WidgetHost { &self.theme, self.editor_state.editor_ui.locale, drop_rect, + // The browser host has no drag-position stream yet, so it can + // never resolve a node target to ring. + None, ); } diff --git a/crates/op-pen-loader/src/image_drop_scene_tests.rs b/crates/op-pen-loader/src/image_drop_scene_tests.rs new file mode 100644 index 000000000..ccbea3273 --- /dev/null +++ b/crates/op-pen-loader/src/image_drop_scene_tests.rs @@ -0,0 +1,68 @@ +//! An image dropped onto a frame must reach the PAINT scene, not just the +//! document. `op-editor-core` proves the fill is written and survives a +//! save/load; this proves the loader turns that same fill into an image layer +//! the canvas actually draws, in cover (`Fill`) mode. + +use jian_scene::layout_scene::{SceneFillLayer, SceneImageFit, SceneNode}; +use op_editor_core::{EditorState, NodeId}; + +use crate::editor_state_to_layout_scene; + +const SRC: &str = "data:image/png;base64,AAAA"; + +fn find<'a>(node: &'a SceneNode, id: &str) -> Option<&'a SceneNode> { + if node.id == id { + return Some(node); + } + node.children.iter().find_map(|child| find(child, id)) +} + +#[test] +fn a_dropped_fill_reaches_the_scene_as_a_cover_image_layer() { + let doc = jian_ops_schema::load_str( + r##"{ + "version":"1.0.0", + "children":[{ + "type":"frame","id":"shot-slot","name":"Screenshot slot", + "x":0,"y":0,"width":400,"height":300, + "children":[ + {"type":"text","id":"hint","content":"Drop a screenshot","x":20,"y":20, + "width":200,"fontSize":14} + ] + }] + }"##, + ) + .expect("fixture parses") + .value; + let mut state = EditorState::from_document(doc); + let target = NodeId::new("shot-slot"); + assert!(state.apply_image_drop(&target, SRC, Some([1200.0, 800.0]))); + + // Save + reload before building the scene: this is the path a user hits + // after closing and reopening the document. + let json = serde_json::to_string(&state.doc).expect("serialize document"); + let reloaded = crate::payload::load_canonical(&json).expect("op-pen-loader reads it back"); + let state = EditorState::from_document(reloaded.value); + + let scene = editor_state_to_layout_scene(&state); + let page = scene.active_page().expect("active page"); + let node = page + .children + .iter() + .find_map(|child| find(child, "shot-slot")) + .expect("target frame in the scene"); + + let layer = node + .fill_layers + .first() + .expect("dropped fill became a scene layer"); + let SceneFillLayer::Image { src, fit, .. } = layer else { + panic!("expected an image fill layer, got {layer:?}"); + }; + assert_eq!(&**src, SRC); + assert_eq!(*fit, SceneImageFit::Fill); + assert!( + !node.children.is_empty(), + "the frame keeps its children — the image fills BEHIND them" + ); +} diff --git a/crates/op-pen-loader/src/lib.rs b/crates/op-pen-loader/src/lib.rs index 82066aa32..e7fd4795c 100644 --- a/crates/op-pen-loader/src/lib.rs +++ b/crates/op-pen-loader/src/lib.rs @@ -38,6 +38,8 @@ mod active_page_scene_tests; mod geometry_mode_scene_tests; #[cfg(test)] mod html_import_scene_tests; +#[cfg(test)] +mod image_drop_scene_tests; #[cfg(feature = "skia-measure")] mod measure_cache; mod path_bounds;