diff --git a/ROADMAP.md b/ROADMAP.md
index 4e0d088d..f43cd015 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged |
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 49163986..46451ae0 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -183,7 +183,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
}
}
let own_cursor = input(app, ui, &resp);
- crate::drop_overlay(&ctx, ctx.content_rect(), tr("Drop to import"));
+ // A browser names no dragged file until the drop, so documents there still say "import".
+ let documents = ctx.input(|i| i.raw.hovered_files.iter().all(|f| f.path.as_deref().is_some_and(crate::is_document)));
+ crate::drop_overlay(&ctx, ctx.content_rect(), if documents { tr("Drop to open") } else { tr("Drop to import") });
crate::guides::end_released(&ctx);
crate::live::track_pointer(app, &ctx);
app.sync_preedit();
@@ -504,10 +506,15 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) {
return false;
}
- // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]).
+ // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]);
+ // a dropped document opens as a file of its own, as on the home screen.
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
for f in dropped {
let path = f.path().to_path_buf();
+ if crate::is_document(&path) {
+ crate::pending_open::open_dropped(app, &f);
+ continue;
+ }
let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default();
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into());
let (params, read) = if path.exists() {
diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs
index c174242a..b532d9a7 100644
--- a/crates/ui-egui/src/home.rs
+++ b/crates/ui-egui/src/home.rs
@@ -180,19 +180,11 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
fn dropped(app: &mut FigApp, ctx: &egui::Context) {
let files = ctx.input(|i| i.raw.dropped_files.clone());
for f in files {
- let path = f.path().to_path_buf();
- if !crate::opens(&path) {
+ if !crate::opens(f.path()) {
app.toast(tr("Drop a .figcraft, .fig, .json, .svg or image file."));
continue;
}
- // `opens` saw an extension, so there is a file name to tell the format by.
- let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
- let (params, read) = if path.exists() {
- (json!({"path": path.to_string_lossy()}), None)
- } else {
- (json!({"name": name}), Some(crate::dropped_bytes(&f, &name)))
- };
- app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read });
+ crate::pending_open::open_dropped(app, &f);
}
}
diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs
index c74c35bf..ebbf6d95 100644
--- a/crates/ui-egui/src/home_tests.rs
+++ b/crates/ui-egui/src/home_tests.rs
@@ -668,9 +668,8 @@ fn dropping_a_native_file_on_the_home_screen_opens_it() {
#[test]
fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switch() {
let dir = Temp::new();
- let path = dir.path("Canvas import.figcraft");
- let mut source = Session::new();
- source.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
+ let path = dir.path("Canvas import.svg");
+ std::fs::write(&path, r#""#).unwrap();
let mut session = Session::new();
let target_uid = session.ds().unwrap().uid;
@@ -696,7 +695,55 @@ fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switc
u.frame(vec![]);
assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the user's active tab stays in front");
- assert_eq!(u.app.session.docs.iter().map(|doc| doc.doc.pages.len()).collect::>(), [2, 1]);
+ let layers = |uid: u64| {
+ let doc = &u.app.session.docs.iter().find(|doc| doc.uid == uid).unwrap().doc;
+ doc.node(doc.pages[0]).unwrap().children.len()
+ };
+ assert_eq!((layers(target_uid), layers(other_uid)), (1, 0), "the drop lands in the tab that received it");
+}
+
+#[test]
+fn dropping_a_document_on_canvas_opens_it_as_a_new_file() {
+ let dir = Temp::new();
+ let path = dir.path("Dropped.figcraft");
+ let mut saved = Session::new();
+ saved.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
+ let mut u = Ui::new(Session::new());
+ u.app.ui.home = false;
+ u.frame(vec![]);
+ let before = u.app.session.doc().unwrap().clone();
+ let s = drop_file(&mut u, &path);
+ assert!(has_text(&s, "Drop to open"), "dragging a document over the canvas says it opens");
+ let s = flat(&u.frame_output(vec![]).shapes);
+ assert!(has_text(&s, "Opening as a new file"), "the card says so too");
+ u.frame(vec![]);
+ assert!(u.app.ui.pending_opens.is_empty());
+ assert_eq!(u.app.session.docs.len(), 2, "a tab of its own");
+ assert_eq!(u.app.session.ds().unwrap().path.as_deref(), Some(path.to_string_lossy().as_ref()), "in front");
+ assert_eq!(u.app.session.docs[0].doc.as_ref(), &before, "the file that was open gets no pages added");
+}
+
+#[test]
+fn a_document_dropped_on_canvas_without_a_file_on_disk_opens_as_a_new_file() {
+ // Every drop in the browser (#916).
+ let dir = Temp::new();
+ let saved = dir.path("Saved.figcraft");
+ Session::new().execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap();
+ let bytes = std::fs::read(&saved).unwrap();
+ let mut u = Ui::new(Session::new());
+ u.app.ui.home = false;
+ u.frame(vec![]);
+ let pos = u.app.canvas.rect.center();
+ let file: egui::DroppedFileHandle = std::sync::Arc::new(DroppedBytes("Shared.figcraft".into(), bytes));
+ u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: vec![file], ..Default::default() });
+ for _ in 0..3 {
+ u.frame(vec![]);
+ }
+ assert!(u.app.ui.pending_opens.is_empty());
+ assert_eq!(u.app.ui.last_error, None);
+ assert_eq!(u.app.session.docs.len(), 2, "a tab of its own");
+ assert_eq!(u.app.session.docs[0].doc.pages.len(), 1, "no pages added to the open file");
+ assert_eq!(u.app.session.docs[1].path, None, "with no save path made up for it");
}
#[test]
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 072aec62..0fd22c53 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -1817,6 +1817,12 @@ pub(crate) fn opens(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
+/// Whether `path` is a whole document (`.figcraft`, `.fig`, file JSON) rather than an image or SVG:
+/// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one.
+pub(crate) fn is_document(path: &Path) -> bool {
+ path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e)))
+}
+
/// Reads the contents of dropped file `name` that has no file on disk to read by path.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture {
diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs
index 501367c0..f5e978c6 100644
--- a/crates/ui-egui/src/pending_open.rs
+++ b/crates/ui-egui/src/pending_open.rs
@@ -32,6 +32,16 @@ pub struct ImportTarget {
pub name: String,
}
+/// Queues dropped file `f` to open as a document of its own: by path, or by its contents when there
+/// is no file on disk to read. Callers checked its extension, so there is a name to tell the format by.
+pub(crate) fn open_dropped(app: &mut FigApp, f: &egui::DroppedFileHandle) {
+ let path = f.path();
+ let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
+ let (params, read) =
+ if path.exists() { (json!({"path": path.to_string_lossy()}), None) } else { (json!({"name": name}), Some(crate::dropped_bytes(f, &name))) };
+ app.ui.pending_opens.push(PendingOpen { label: name, params, command: "file.open", target: None, read });
+}
+
fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option {
let target = match pending.target {
Some(target) => target,