mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
Merge pull request #928 from AganzeFelicite/fix/web-drop-opens-documents-916
M7: a document dropped on the canvas opens as a new file (#916)
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10"/></svg>"#).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::<Vec<_>>(), [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]
|
||||
|
||||
@@ -1825,6 +1825,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 {
|
||||
|
||||
@@ -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<Value> {
|
||||
let target = match pending.target {
|
||||
Some(target) => target,
|
||||
|
||||
Reference in New Issue
Block a user