feat(editor): drop an image file onto a node to set its fill

Dropping a png/jpg/webp used to be silently ignored — placing a
screenshot meant importing, dragging into position and matching sizes
by hand. Now a drop resolves the deepest fillable node under the
cursor (walking up past text and icons), writes a cover image fill in
one undo step, and rings the target while hovering so the outcome is
visible before release. Empty-canvas drops insert an image node at the
point; document drops keep their open/import behavior. Drag positions
come from an AppKit probe on macOS; other platforms degrade to a
centered insert.
This commit is contained in:
Fini 2026-07-28 00:29:39 +08:00
parent 80d9b2aca3
commit 322ae23760
25 changed files with 1186 additions and 18 deletions

View file

@ -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<crate::NodeId>,
/// 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.

View file

@ -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,

View file

@ -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<NodeId> {
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<NodeId> {
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<NodeId> {
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)?;

View file

@ -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<NodeId> {
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;

View file

@ -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]));
}

View file

@ -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<ImageFillMode>,
) -> 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() {

View file

@ -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;

View file

@ -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<Rect>,
) {
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);

View file

@ -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",

View file

@ -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<DesktopEvent> 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<DesktopEvent> 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, .. } => {

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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::<NSView>().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
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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<provider_probe_host::ProviderConnectJob>,
/// Startup reconnect replay queue (see `agent_connect_store`).
provider_reconnect_queue: Vec<op_editor_core::AgentProvider>,
/// 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<op_editor_core::PencilCursorStyle>,
/// Providers the store remembers as LAST KNOWN GOOD — seeded from

View file

@ -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<std::path::PathBuf> {
/// 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<EmbeddedImage> {
pub(crate) fn read_as_data_url(path: &Path) -> std::io::Result<EmbeddedImage> {
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).

View file

@ -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;

View file

@ -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<NodeId> {
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<Rect> {
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<NodeId>) -> 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<NodeId> {
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;

View file

@ -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));
}

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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"
);
}

View file

@ -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;