Add support for opening multiple selected files from disk (#4128)
This commit is contained in:
parent
dff8ac5511
commit
9d876ab27d
|
|
@ -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,14 +206,22 @@ 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) {
|
||||||
|
Ok(content) => {
|
||||||
let message = DesktopWrapperMessage::FileDialogResult { path, content, context };
|
let message = DesktopWrapperMessage::FileDialogResult { path, content, context };
|
||||||
app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
|
app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
|
||||||
}
|
}
|
||||||
|
Err(e) => tracing::error!("Failed to read file {}: {}", path.display(), e),
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
DesktopFrontendMessage::SaveFileDialog {
|
DesktopFrontendMessage::SaveFileDialog {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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 results = await Promise.all(
|
||||||
|
Array.from(element.files).map(async (file) => ({
|
||||||
|
filename: file.name,
|
||||||
|
type: file.type,
|
||||||
|
content: new Uint8Array(await file.arrayBuffer()),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
resolve(results);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = element.files[0];
|
||||||
const content =
|
const content =
|
||||||
textOrData === "text"
|
textOrData === "text"
|
||||||
? await file.text()
|
? await file.text()
|
||||||
: textOrData === "data"
|
: textOrData === "data"
|
||||||
? new Uint8Array(await file.arrayBuffer())
|
? new Uint8Array(await file.arrayBuffer())
|
||||||
: { text: await file.text(), data: new Uint8Array(await file.arrayBuffer()) };
|
: { text: await file.text(), data: new Uint8Array(await file.arrayBuffer()) };
|
||||||
|
resolve({ filename: file.name, type: file.type, content });
|
||||||
resolve({ filename, type, content });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ capture: false, once: true },
|
{ capture: false, once: true },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue