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:
parent
80d9b2aca3
commit
322ae23760
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
88
crates/op-editor-core/src/image_drop.rs
Normal file
88
crates/op-editor-core/src/image_drop.rs
Normal 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;
|
||||
191
crates/op-editor-core/src/image_drop_tests.rs
Normal file
191
crates/op-editor-core/src/image_drop_tests.rs
Normal 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]));
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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, .. } => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
50
crates/op-host-desktop/src/drag_cursor.rs
Normal file
50
crates/op-host-desktop/src/drag_cursor.rs
Normal 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
|
||||
}
|
||||
154
crates/op-host-desktop/src/image_drop_host.rs
Normal file
154
crates/op-host-desktop/src/image_drop_host.rs
Normal 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;
|
||||
162
crates/op-host-desktop/src/image_drop_host_tests.rs
Normal file
162
crates/op-host-desktop/src/image_drop_host_tests.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
136
crates/op-host-native/src/widget_host/image_drop_target.rs
Normal file
136
crates/op-host-native/src/widget_host/image_drop_target.rs
Normal 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;
|
||||
151
crates/op-host-native/src/widget_host/image_drop_target_tests.rs
Normal file
151
crates/op-host-native/src/widget_host/image_drop_target_tests.rs
Normal 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));
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
68
crates/op-pen-loader/src/image_drop_scene_tests.rs
Normal file
68
crates/op-pen-loader/src/image_drop_scene_tests.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue