diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1247,6 +1247,7 @@ dependencies = [
  "designcraft-geom",
  "designcraft-idml",
  "designcraft-render",
+ "designcraft-textimport",
  "designcraft-tools",
  "egui",
  "egui_kittest",
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -19,6 +19,7 @@ designcraft-compose = { workspace = true }
 designcraft-render = { workspace = true }
 designcraft-tools = { workspace = true }
 designcraft-engine = { workspace = true }
+designcraft-textimport = { workspace = true }
 unicode-bidi = { workspace = true }
 unicode-bidi-mirroring = { workspace = true }
 unicode-segmentation = { workspace = true }
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -192,6 +192,17 @@ pub fn drop_point(app: &DesignApp, screen: Option<Pos2>) -> Option<(SpreadRef, P
     layout.spread_at(Xf::new(rect, app.view()?).to_canvas(pos))
 }
 
+/// The empty or graphic frame under a pointer over the canvas: a graphic dropped there fills it
+/// instead of becoming a new object. `None` when the pointer isn't over the canvas or over such a frame.
+pub fn drop_frame(app: &DesignApp, screen: Option<Pos2>) -> Option<designcraft_doc::ItemId> {
+    let rect = app.canvas_rect?;
+    let pos = screen.filter(|p| rect.contains(*p))?;
+    let v = app.view()?;
+    let (id, _) = item_at(app, Xf::new(rect, v).to_canvas(pos), v.zoom)?;
+    let it = app.session.active()?.doc.item(id)?;
+    matches!(it.content, designcraft_doc::Content::Unassigned | designcraft_doc::Content::Graphic(_)).then_some(id)
+}
+
 /// Absolute page index under the view centre.
 pub fn current_page(app: &DesignApp) -> Option<usize> {
     let st = app.session.active()?;
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -1333,8 +1333,9 @@ impl DesignApp {
         self.open_dropped_at(path, None)
     }
 
-    /// [`Self::open_dropped`] at the pointer: a graphic's top left lands where the file was
-    /// dropped on the canvas, text goes to a new frame on the document page there.
+    /// [`Self::open_dropped`] at the pointer: a graphic dropped on an empty or graphic frame fills
+    /// it, elsewhere its top left lands where the file was dropped on the canvas; text goes to a
+    /// new frame on the document page there.
     pub fn open_dropped_at(&mut self, path: &str, pointer: Option<egui::Pos2>) -> Result<Value, String> {
         if path.to_ascii_lowercase().ends_with(".ase") {
             return self.run("swatch.load", json!({"path": path}));
@@ -1347,6 +1348,12 @@ impl DesignApp {
             return self.open_file("file.open", json!({"path": path}));
         }
         let mut params = json!({"path": path});
+        // A graphic dropped on an empty or graphic frame fills it, as File > Place does. Text keeps
+        // getting a frame of its own, so it never replaces the image in a graphic frame.
+        let frame = if designcraft_textimport::is_text_file(path) { None } else { canvas::drop_frame(self, pointer) };
+        if let (Some(frame), Some(o)) = (frame, params.as_object_mut()) {
+            o.insert("frame".into(), json!(frame.0));
+        }
         if let (Some((sr, at)), Some(o)) = (canvas::drop_point(self, pointer), params.as_object_mut()) {
             o.insert("spread".into(), serde_json::to_value(sr).unwrap_or(Value::Null));
             o.insert("x".into(), json!(at.x));
@@ -2521,6 +2528,41 @@ mod tests {
         }
     }
 
+    #[test]
+    fn a_graphic_dropped_on_a_frame_fills_it() {
+        let mut app = DesignApp::new(Session::new(), Services::default());
+        app.run("file.new", json!({})).unwrap();
+        let frame = app.run("frame.create", json!({"rect": [36, 36, 136, 136]})).unwrap();
+        app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)));
+        let _ = app.view_mut();
+        let (view, rect) = (app.view().unwrap(), app.canvas_rect.unwrap());
+        let st = app.session.active().unwrap();
+        let layout = designcraft_tools::CanvasLayout::new(&st.doc, false);
+        let slot = layout.slot(designcraft_doc::SpreadRef::Doc(0)).unwrap();
+        let xf = canvas::Xf::new(rect, view);
+        let inside = xf.to_screen(slot.to_canvas(designcraft_geom::Point::new(80.0, 80.0)));
+        let outside = xf.to_screen(slot.to_canvas(designcraft_geom::Point::new(300.0, 300.0)));
+        let path = std::env::temp_dir().join(format!("designcraft-ui-{}-dropframe.png", std::process::id()));
+        std::fs::write(&path, designcraft_render::Rendered { width: 10, height: 10, pixels: vec![200; 400] }.to_png()).unwrap();
+        let r = app.open_dropped_at(&path.to_string_lossy(), Some(inside));
+        let _ = std::fs::remove_file(&path);
+        let placed = r.unwrap();
+        assert_eq!(placed["id"], frame["id"], "the frame under the pointer is filled");
+        let d = &app.session.doc().unwrap().doc;
+        assert_eq!(d.spreads[0].items.len(), 1, "no new object");
+        assert!(matches!(d.item(designcraft_doc::ItemId(frame["id"].as_u64().unwrap())).unwrap().content, designcraft_doc::Content::Graphic(_)));
+        assert!(canvas::drop_frame(&app, Some(outside)).is_none(), "an empty spot on the page is no frame");
+        // Text dropped there gets a frame of its own and leaves the image in place.
+        let text = std::env::temp_dir().join(format!("designcraft-ui-{}-dropframe.txt", std::process::id()));
+        std::fs::write(&text, "Dropped words").unwrap();
+        let r = app.open_dropped_at(&text.to_string_lossy(), Some(inside));
+        let _ = std::fs::remove_file(&text);
+        r.unwrap();
+        let d = &app.session.doc().unwrap().doc;
+        assert_eq!(d.spreads[0].items.len(), 2, "the text has a new frame");
+        assert!(matches!(d.item(designcraft_doc::ItemId(frame["id"].as_u64().unwrap())).unwrap().content, designcraft_doc::Content::Graphic(_)));
+    }
+
     #[test]
     fn a_file_dropped_while_a_parent_is_open_lands_on_the_parent() {
         let mut app = DesignApp::new(Session::new(), Services::default());
