Add support for opening multiple selected files from disk (#4128)

This commit is contained in:
Keavon Chambers 2026-05-07 21:21:45 -07:00 committed by GitHub
parent dff8ac5511
commit 9d876ab27d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 108 additions and 32 deletions

View File

@ -198,7 +198,7 @@ impl App {
}; };
self.send_or_queue_web_message(bytes); self.send_or_queue_web_message(bytes);
} }
DesktopFrontendMessage::OpenFileDialog { title, filters, context } => { DesktopFrontendMessage::OpenFileDialog { title, filters, multiple, context } => {
let app_event_scheduler = self.app_event_scheduler.clone(); let app_event_scheduler = self.app_event_scheduler.clone();
let _ = thread::spawn(move || { let _ = thread::spawn(move || {
let mut dialog = AsyncFileDialog::new().set_title(title); let mut dialog = AsyncFileDialog::new().set_title(title);
@ -206,13 +206,21 @@ impl App {
dialog = dialog.add_filter(filter.name, &filter.extensions); dialog = dialog.add_filter(filter.name, &filter.extensions);
} }
let show_dialog = async move { dialog.pick_file().await.map(|f| f.path().to_path_buf()) }; let handles = if multiple {
futures::executor::block_on(dialog.pick_files()).unwrap_or_default()
} else {
futures::executor::block_on(dialog.pick_file()).into_iter().collect()
};
if let Some(path) = futures::executor::block_on(show_dialog) for handle in handles {
&& let Ok(content) = fs::read(&path) let path = handle.path().to_path_buf();
{ match fs::read(&path) {
let message = DesktopWrapperMessage::FileDialogResult { path, content, context }; Ok(content) => {
app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); let message = DesktopWrapperMessage::FileDialogResult { path, content, context };
app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
}
Err(e) => tracing::error!("Failed to read file {}: {}", path.display(), e),
}
} }
}); });
} }

View File

@ -14,6 +14,7 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
dispatcher.respond(DesktopFrontendMessage::OpenFileDialog { dispatcher.respond(DesktopFrontendMessage::OpenFileDialog {
title: "Open Document".to_string(), title: "Open Document".to_string(),
filters: vec![], filters: vec![],
multiple: true,
context: OpenFileDialogContext::Open, context: OpenFileDialogContext::Open,
}); });
} }
@ -21,6 +22,7 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
dispatcher.respond(DesktopFrontendMessage::OpenFileDialog { dispatcher.respond(DesktopFrontendMessage::OpenFileDialog {
title: "Import File".to_string(), title: "Import File".to_string(),
filters: vec![], filters: vec![],
multiple: false,
context: OpenFileDialogContext::Import, context: OpenFileDialogContext::Import,
}); });
} }

View File

@ -16,6 +16,7 @@ pub enum DesktopFrontendMessage {
OpenFileDialog { OpenFileDialog {
title: String, title: String,
filters: Vec<FileFilter>, filters: Vec<FileFilter>,
multiple: bool,
context: OpenFileDialogContext, context: OpenFileDialogContext,
}, },
SaveFileDialog { SaveFileDialog {
@ -102,6 +103,7 @@ pub struct FileFilter {
pub extensions: Vec<String>, pub extensions: Vec<String>,
} }
#[derive(Clone, Copy)]
pub enum OpenFileDialogContext { pub enum OpenFileDialogContext {
Open, Open,
Import, Import,

View File

@ -6442,6 +6442,7 @@ pub struct InputPersistentMetadata {
/// A general datastore than can store key value pairs of any types for any input /// A general datastore than can store key value pairs of any types for any input
/// Each instance of the input node needs to store its own data, since it can lose the reference to its /// Each instance of the input node needs to store its own data, since it can lose the reference to its
/// node definition if the node signature is modified by the user. For example adding/removing/renaming an import/export of a network node. /// node definition if the node signature is modified by the user. For example adding/removing/renaming an import/export of a network node.
#[serde(serialize_with = "graphene_std::vector::serialize_hashmap_as_sorted_object")]
pub input_data: HashMap<String, Value>, pub input_data: HashMap<String, Value>,
// An input can override a widget, which would otherwise be automatically generated from the type // An input can override a widget, which would otherwise be automatically generated from the type
// The string is the identifier to the widget override function stored in INPUT_OVERRIDES // The string is the identifier to the widget override function stored in INPUT_OVERRIDES

View File

@ -94,8 +94,8 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
}); });
subscriptions.subscribeFrontendMessage("TriggerOpen", async () => { subscriptions.subscribeFrontendMessage("TriggerOpen", async () => {
const data = await upload(`image/*,.${editor.fileExtension()}`, "data"); const files = await upload(`image/*,.${editor.fileExtension()}`, "data", true);
editor.openFile(data.filename, data.content); files.forEach((file) => editor.openFile(file.filename, file.content));
}); });
subscriptions.subscribeFrontendMessage("TriggerImport", async () => { subscriptions.subscribeFrontendMessage("TriggerImport", async () => {

View File

@ -32,29 +32,44 @@ export function downloadFile(filename: string, content: Uint8Array) {
export async function upload(accept: string, textOrData: "text"): Promise<UploadResult<string>>; export async function upload(accept: string, textOrData: "text"): Promise<UploadResult<string>>;
export async function upload(accept: string, textOrData: "data"): Promise<UploadResult<Uint8Array>>; export async function upload(accept: string, textOrData: "data"): Promise<UploadResult<Uint8Array>>;
export async function upload(accept: string, textOrData: "both"): Promise<UploadResult<{ text: string; data: Uint8Array }>>; export async function upload(accept: string, textOrData: "both"): Promise<UploadResult<{ text: string; data: Uint8Array }>>;
export async function upload(accept: string, textOrData: "text" | "data" | "both"): Promise<UploadResult<string | Uint8Array | { text: string; data: Uint8Array }>> { export async function upload(accept: string, textOrData: "data", multiple: true): Promise<UploadResult<Uint8Array>[]>;
export async function upload(
accept: string,
textOrData: "text" | "data" | "both",
multiple = false,
): Promise<UploadResult<string | Uint8Array | { text: string; data: Uint8Array }> | UploadResult<Uint8Array>[]> {
return new Promise((resolve) => { return new Promise((resolve) => {
const element = document.createElement("input"); const element = document.createElement("input");
element.type = "file"; element.type = "file";
element.accept = accept; element.accept = accept;
element.multiple = multiple;
element.addEventListener( element.addEventListener(
"change", "change",
async () => { async () => {
if (element.files?.length) { if (!element.files?.length) return;
const file = element.files[0];
const filename = file.name; // The `multiple: true` overload constrains `textOrData` to "data", so we know each file produces a Uint8Array
const type = file.type; if (multiple) {
const content = const results = await Promise.all(
textOrData === "text" Array.from(element.files).map(async (file) => ({
? await file.text() filename: file.name,
: textOrData === "data" type: file.type,
? new Uint8Array(await file.arrayBuffer()) content: new Uint8Array(await file.arrayBuffer()),
: { text: await file.text(), data: new Uint8Array(await file.arrayBuffer()) }; })),
);
resolve({ filename, type, content }); resolve(results);
return;
} }
const file = element.files[0];
const content =
textOrData === "text"
? await file.text()
: textOrData === "data"
? new Uint8Array(await file.arrayBuffer())
: { text: await file.text(), data: new Uint8Array(await file.arrayBuffer()) };
resolve({ filename: file.name, type: file.type, content });
}, },
{ capture: false, once: true }, { capture: false, once: true },
); );

View File

@ -5,14 +5,20 @@ use core_types::uuid::generate_uuid;
use dyn_any::DynAny; use dyn_any::DynAny;
use glam::DVec2; use glam::DVec2;
use kurbo::{BezPath, PathEl, Point}; use kurbo::{BezPath, PathEl, Point};
use serde::de::{SeqAccess, Visitor};
use serde::ser::SerializeSeq;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fmt;
use std::hash::BuildHasher; use std::hash::BuildHasher;
use std::hash::Hash;
/// Represents a procedural change to the [`PointDomain`] in [`Vector`]. /// Represents a procedural change to the [`PointDomain`] in [`Vector`].
#[derive(Clone, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PointModification { pub struct PointModification {
add: Vec<PointId>, add: Vec<PointId>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
remove: HashSet<PointId>, remove: HashSet<PointId>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))] #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))]
delta: HashMap<PointId, DVec2>, delta: HashMap<PointId, DVec2>,
@ -79,6 +85,7 @@ impl PointModification {
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SegmentModification { pub struct SegmentModification {
add: Vec<SegmentId>, add: Vec<SegmentId>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
remove: HashSet<SegmentId>, remove: HashSet<SegmentId>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))] #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))]
start_point: HashMap<SegmentId, PointId>, start_point: HashMap<SegmentId, PointId>,
@ -250,6 +257,7 @@ impl SegmentModification {
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RegionModification { pub struct RegionModification {
add: Vec<RegionId>, add: Vec<RegionId>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
remove: HashSet<RegionId>, remove: HashSet<RegionId>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))] #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))]
segment_range: HashMap<RegionId, std::ops::RangeInclusive<SegmentId>>, segment_range: HashMap<RegionId, std::ops::RangeInclusive<SegmentId>>,
@ -297,7 +305,9 @@ pub struct VectorModification {
points: PointModification, points: PointModification,
segments: SegmentModification, segments: SegmentModification,
regions: RegionModification, regions: RegionModification,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
add_g1_continuous: HashSet<[HandleId; 2]>, add_g1_continuous: HashSet<[HandleId; 2]>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
remove_g1_continuous: HashSet<[HandleId; 2]>, remove_g1_continuous: HashSet<[HandleId; 2]>,
} }
@ -520,27 +530,65 @@ impl graphene_hash::CacheHash for VectorModification {
} }
} }
// Do we want to enforce that all serialized/deserialized hashmaps are a vec of tuples? // TODO: Do we want to enforce that all serialized/deserialized hashmaps are a vec of tuples?
// TODO: Eventually remove this document upgrade code // TODO: Eventually remove this document upgrade code
use serde::de::{SeqAccess, Visitor}; /// Serializes as sorted `[[key, value], ...]` (sequence of pairs)
use serde::ser::SerializeSeq;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::hash::Hash;
pub fn serialize_hashmap<K, V, S, H>(hashmap: &HashMap<K, V, H>, serializer: S) -> Result<S::Ok, S::Error> pub fn serialize_hashmap<K, V, S, H>(hashmap: &HashMap<K, V, H>, serializer: S) -> Result<S::Ok, S::Error>
where where
K: Serialize + Eq + Hash, K: Serialize + Eq + Hash + Ord,
V: Serialize, V: Serialize,
S: Serializer, S: Serializer,
H: BuildHasher, H: BuildHasher,
{ {
let mut seq = serializer.serialize_seq(Some(hashmap.len()))?; // Sort entries by key so the serialized output is deterministic across runs (HashMap iteration order is randomized).
for (key, value) in hashmap { // Removes a major source of churn in saved-document diffs without affecting load behavior.
let mut entries: Vec<_> = hashmap.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
let mut seq = serializer.serialize_seq(Some(entries.len()))?;
for (key, value) in entries {
seq.serialize_element(&(key, value))?; seq.serialize_element(&(key, value))?;
} }
seq.end() seq.end()
} }
/// Serializes as sorted `{"key": value, ...}` (JSON object)
pub fn serialize_hashmap_as_sorted_object<K, V, S, H>(hashmap: &HashMap<K, V, H>, serializer: S) -> Result<S::Ok, S::Error>
where
K: Serialize + Eq + Hash + Ord,
V: Serialize,
S: Serializer,
H: BuildHasher,
{
use serde::ser::SerializeMap;
let mut entries: Vec<_> = hashmap.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
let mut map = serializer.serialize_map(Some(entries.len()))?;
for (key, value) in entries {
map.serialize_entry(key, value)?;
}
map.end()
}
/// Serializes as sorted `[value, ...]` (JSON array)
pub fn serialize_hashset<T, S, H>(set: &HashSet<T, H>, serializer: S) -> Result<S::Ok, S::Error>
where
T: Serialize + Eq + Hash + Ord,
S: Serializer,
H: BuildHasher,
{
let mut entries: Vec<_> = set.iter().collect();
entries.sort();
let mut seq = serializer.serialize_seq(Some(entries.len()))?;
for value in entries {
seq.serialize_element(value)?;
}
seq.end()
}
pub fn deserialize_hashmap<'de, K, V, D, H>(deserializer: D) -> Result<HashMap<K, V, H>, D::Error> pub fn deserialize_hashmap<'de, K, V, D, H>(deserializer: D) -> Result<HashMap<K, V, H>, D::Error>
where where
K: Deserialize<'de> + Eq + Hash, K: Deserialize<'de> + Eq + Hash,

View File

@ -32,7 +32,7 @@ pub mod vector {
pub use vector_types::vector::click_target; pub use vector_types::vector::click_target;
pub use vector_types::vector::misc::HandleId; pub use vector_types::vector::misc::HandleId;
pub use vector_types::vector::{PointId, RegionId, SegmentId, StrokeId}; pub use vector_types::vector::{PointId, RegionId, SegmentId, StrokeId};
pub use vector_types::vector::{deserialize_hashmap, serialize_hashmap}; pub use vector_types::vector::{deserialize_hashmap, serialize_hashmap, serialize_hashmap_as_sorted_object};
// Re-export HandleExt trait and NoHashBuilder // Re-export HandleExt trait and NoHashBuilder
pub use vector_types::vector::HandleExt; pub use vector_types::vector::HandleExt;