mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-20 03:18:06 +08:00
Unify file opening, importing, and pasting into a file ingest handler (#4548)
* Unify file and data ingest * Code review --------- Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
@@ -427,6 +427,7 @@ impl Dispatcher {
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::messages::portfolio::ingest::utility_types::IngestAction;
|
||||
pub use crate::test_utils::test_prelude::*;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -478,9 +479,11 @@ mod test {
|
||||
"Demo artwork '{document_name}' has more than 1 line (remember to open and re-save it in Graphite)",
|
||||
);
|
||||
|
||||
let responses = editor.editor.handle_message(PortfolioMessage::OpenFile {
|
||||
path: file_name.into(),
|
||||
content: document_serialized_content.bytes().collect(),
|
||||
let responses = editor.editor.handle_message(IngestMessage::Ingest {
|
||||
data: document_serialized_content.into_bytes(),
|
||||
action: IngestAction::Open,
|
||||
mime_type: String::new(),
|
||||
path: Some(file_name.into()),
|
||||
});
|
||||
|
||||
// Check if the graph renders
|
||||
|
||||
@@ -5,14 +5,12 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions:
|
||||
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface;
|
||||
use crate::messages::portfolio::document::utility_types::nodes::SelectedNodes;
|
||||
use crate::messages::portfolio::resource_upload::utility_types::UploadTarget;
|
||||
use crate::messages::prelude::*;
|
||||
use crate::messages::tool::common_functionality::graph_modification_utils;
|
||||
use crate::messages::tool::utility_types::ToolType;
|
||||
use graph_craft::application_io::resource::{DataSource, ResourceHash};
|
||||
use graph_craft::document::NodeId;
|
||||
use graphene_std::Color;
|
||||
use graphene_std::raster::Image;
|
||||
use graphene_std::vector::misc::{BezierHandles, HandleId, point_to_dvec2, segment_to_handles};
|
||||
use graphene_std::vector::{PointId, SegmentId, VectorModificationType};
|
||||
use graphite_proc_macros::{ExtractField, message_handler_data};
|
||||
@@ -52,24 +50,6 @@ impl MessageHandler<ClipboardMessage, ClipboardMessageContext<'_>> for Clipboard
|
||||
responses.add(FrontendMessage::TriggerSelectionWrite { content: text });
|
||||
}
|
||||
}
|
||||
ClipboardContentRaw::Svg(svg) => {
|
||||
responses.add(PortfolioMessage::InsertSvg {
|
||||
svg,
|
||||
name: None,
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
});
|
||||
}
|
||||
ClipboardContentRaw::Image { data, width, height } => {
|
||||
responses.add(ResourceUploadMessage::Upload {
|
||||
name: None,
|
||||
data: Image::from_image_data(&data, width, height).to_png().into(),
|
||||
target: UploadTarget::Layer {
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
ClipboardMessage::ReadSelection { content, cut } => {
|
||||
if let Some(text) = content {
|
||||
|
||||
@@ -10,8 +10,6 @@ use graphene_std::vector::Vector;
|
||||
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ClipboardContentRaw {
|
||||
Text(String),
|
||||
Svg(String),
|
||||
Image { data: Vec<u8>, width: u32, height: u32 },
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::IconName;
|
||||
use super::utility_types::{MouseCursorIcon, PersistedState};
|
||||
use crate::messages::app_window::app_window_message_handler::AppWindowPlatform;
|
||||
use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, FileFilter, RasterizedImage};
|
||||
use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, FileDialogOptions, FileFilter, RasterizedImage};
|
||||
use crate::messages::input_mapper::utility_types::misc::ActionShortcut;
|
||||
use crate::messages::layout::utility_types::widget_prelude::*;
|
||||
use crate::messages::portfolio::document::node_graph::utility_types::{
|
||||
@@ -9,6 +9,7 @@ use crate::messages::portfolio::document::node_graph::utility_types::{
|
||||
};
|
||||
use crate::messages::portfolio::document::utility_types::nodes::{LayerPanelEntry, LayerStructureEntry};
|
||||
use crate::messages::portfolio::document::utility_types::wires::{WirePath, WirePathUpdate};
|
||||
use crate::messages::portfolio::ingest::utility_types::IngestAction;
|
||||
use crate::messages::portfolio::utility_types::WorkspacePanelLayout;
|
||||
use crate::messages::prelude::*;
|
||||
use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary;
|
||||
@@ -87,14 +88,9 @@ pub enum FrontendMessage {
|
||||
commit_date: String,
|
||||
},
|
||||
TriggerDisplayThirdPartyLicensesDialog,
|
||||
TriggerOpen {
|
||||
filters: Vec<FileFilter>,
|
||||
},
|
||||
TriggerImport {
|
||||
filters: Vec<FileFilter>,
|
||||
},
|
||||
TriggerUploadResource {
|
||||
filters: Vec<FileFilter>,
|
||||
TriggerBrowse {
|
||||
options: FileDialogOptions,
|
||||
action: IngestAction,
|
||||
},
|
||||
TriggerSaveDocument {
|
||||
document_id: DocumentId,
|
||||
|
||||
@@ -82,6 +82,7 @@ impl FileType {
|
||||
FileFilter {
|
||||
name: name.into(),
|
||||
extensions: vec![self.extension().into()],
|
||||
mime_types: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,4 +118,13 @@ pub struct RasterizedImage {
|
||||
pub struct FileFilter {
|
||||
pub name: String,
|
||||
pub extensions: Vec<String>,
|
||||
#[serde(rename = "mimeTypes")]
|
||||
pub mime_types: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FileDialogOptions {
|
||||
pub filters: Vec<FileFilter>,
|
||||
pub multiple: bool,
|
||||
}
|
||||
|
||||
@@ -444,8 +444,8 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping {
|
||||
entry!(KeyDown(Tab); modifiers=[Control, Shift], action_dispatch=PortfolioMessage::PrevDocument),
|
||||
entry!(KeyDown(KeyW); modifiers=[Accel], action_dispatch=PortfolioMessage::CloseActiveDocumentWithConfirmation),
|
||||
entry!(KeyDown(KeyW); modifiers=[Accel, Alt], action_dispatch=PortfolioMessage::CloseAllDocumentsWithConfirmation),
|
||||
entry!(KeyDown(KeyO); modifiers=[Accel], action_dispatch=PortfolioMessage::Open),
|
||||
entry!(KeyDown(KeyI); modifiers=[Accel], action_dispatch=PortfolioMessage::Import),
|
||||
entry!(KeyDown(KeyO); modifiers=[Accel], action_dispatch=IngestMessage::Open),
|
||||
entry!(KeyDown(KeyI); modifiers=[Accel], action_dispatch=IngestMessage::Import),
|
||||
entry!(KeyDown(KeyR); modifiers=[Alt], action_dispatch=PortfolioMessage::ToggleRulers),
|
||||
entry!(KeyDown(KeyD); modifiers=[Alt], action_dispatch=PortfolioMessage::ToggleDataPanelOpen),
|
||||
entry!(KeyDown(Enter); modifiers=[Alt], action_dispatch=PortfolioMessage::ToggleFocusDocument),
|
||||
|
||||
@@ -123,8 +123,8 @@ impl LayoutHolder for MenuBarMessageHandler {
|
||||
MenuListEntry::new("Open…")
|
||||
.label("Open…")
|
||||
.icon("Folder")
|
||||
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::Open))
|
||||
.on_commit(|_| PortfolioMessage::Open.into()),
|
||||
.tooltip_shortcut(action_shortcut!(IngestMessageDiscriminant::Open))
|
||||
.on_commit(|_| IngestMessage::Open.into()),
|
||||
MenuListEntry::new("Open Demo Artwork…")
|
||||
.label("Open Demo Artwork…")
|
||||
.icon("Image")
|
||||
@@ -163,8 +163,8 @@ impl LayoutHolder for MenuBarMessageHandler {
|
||||
MenuListEntry::new("Import…")
|
||||
.label("Import…")
|
||||
.icon("FileImport")
|
||||
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::Import))
|
||||
.on_commit(|_| PortfolioMessage::Import.into())
|
||||
.tooltip_shortcut(action_shortcut!(IngestMessageDiscriminant::Import))
|
||||
.on_commit(|_| IngestMessage::Import.into())
|
||||
.disabled(no_active_document),
|
||||
MenuListEntry::new("Export…")
|
||||
.label("Export…")
|
||||
|
||||
@@ -7,10 +7,9 @@ use crate::messages::portfolio::document::data_panel::DataPanelMessage;
|
||||
use crate::messages::portfolio::document::overlays::utility_types::{OverlayContext, OverlaysType};
|
||||
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GridSnapping};
|
||||
use crate::messages::portfolio::resource_upload::utility_types::ImageResource;
|
||||
use crate::messages::portfolio::utility_types::PanelType;
|
||||
use crate::messages::prelude::*;
|
||||
use glam::{DAffine2, IVec2};
|
||||
use glam::{DAffine2, IVec2, UVec2};
|
||||
use graph_craft::document::NodeId;
|
||||
use graphene_std::Appearance;
|
||||
use graphene_std::raster::BlendMode;
|
||||
@@ -116,7 +115,8 @@ pub enum DocumentMessage {
|
||||
},
|
||||
InsertImage {
|
||||
name: Option<String>,
|
||||
image: ImageResource,
|
||||
data: Arc<[u8]>,
|
||||
size: UVec2,
|
||||
mouse: Option<(f64, f64)>,
|
||||
parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>,
|
||||
/// When true (file-open flow), place the image at the document origin so `WrapContentInArtboard`
|
||||
|
||||
@@ -798,13 +798,14 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
}
|
||||
DocumentMessage::InsertImage {
|
||||
name,
|
||||
image,
|
||||
data,
|
||||
size,
|
||||
mouse,
|
||||
parent_and_insert_index,
|
||||
place_at_origin,
|
||||
} => {
|
||||
let layer_parent = self.new_layer_parent(true);
|
||||
let image_size = DVec2::new(image.width as f64, image.height as f64);
|
||||
let image_size = size.as_dvec2();
|
||||
|
||||
let mut transform = if place_at_origin {
|
||||
// File-open flow: place at document origin without centering so `WrapContentInArtboard` can wrap it
|
||||
@@ -827,7 +828,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
|
||||
responses.add(DocumentMessage::StartTransaction);
|
||||
|
||||
let layer = graph_modification_utils::new_image_layer(image.resource_id, layer_node_id, layer_parent, responses);
|
||||
let layer = graph_modification_utils::new_image_layer(data, layer_node_id, layer_parent, responses);
|
||||
|
||||
if let Some(name) = name {
|
||||
responses.add(NodeGraphMessage::SetDisplayName {
|
||||
@@ -1099,6 +1100,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
filters: vec![FileFilter {
|
||||
name: "Graphite Document".into(),
|
||||
extensions: vec![extension.into()],
|
||||
mime_types: Vec::new(),
|
||||
}],
|
||||
content: content.into(),
|
||||
})
|
||||
|
||||
@@ -3,13 +3,13 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::NodeTemplate;
|
||||
use crate::messages::prelude::*;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graph_craft::application_io::resource::ResourceId;
|
||||
use graph_craft::document::NodeId;
|
||||
use graphene_std::Color;
|
||||
use graphene_std::raster::BlendMode;
|
||||
use graphene_std::text::{Font, TypesettingConfig};
|
||||
use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, PaintOrder, Stroke};
|
||||
use graphene_std::vector::{Gradient, VectorModificationType};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[impl_message(Message, DocumentMessage, GraphOperation)]
|
||||
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
@@ -141,7 +141,7 @@ pub enum GraphOperationMessage {
|
||||
},
|
||||
NewBitmapLayer {
|
||||
id: NodeId,
|
||||
resource_id: ResourceId,
|
||||
data: Arc<[u8]>,
|
||||
parent: LayerNodeIdentifier,
|
||||
insert_index: usize,
|
||||
},
|
||||
|
||||
@@ -267,15 +267,10 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
|
||||
responses.add_front(NodeGraphMessage::SelectedNodesSet { nodes: vec![id] });
|
||||
responses.add(NodeGraphMessage::RunDocumentGraph);
|
||||
}
|
||||
GraphOperationMessage::NewBitmapLayer {
|
||||
id,
|
||||
resource_id,
|
||||
parent,
|
||||
insert_index,
|
||||
} => {
|
||||
GraphOperationMessage::NewBitmapLayer { id, data, parent, insert_index } => {
|
||||
let mut modify_inputs = ModifyInputsContext::new(network_interface, responses);
|
||||
let layer = modify_inputs.create_layer(id);
|
||||
modify_inputs.insert_image_data(resource_id, layer);
|
||||
modify_inputs.insert_image_data(data, layer);
|
||||
network_interface.move_layer_to_stack(layer, parent, insert_index, &[]);
|
||||
responses.add(NodeGraphMessage::RunDocumentGraph);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientIn
|
||||
use graphene_std::vector::{Gradient, GradientRamp, Vector, VectorModification, VectorModificationType};
|
||||
use graphene_std::{Artboard, Color, Graphic};
|
||||
use kurbo::BezPath;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum TransformIn {
|
||||
@@ -269,11 +270,14 @@ impl<'a> ModifyInputsContext<'a> {
|
||||
self.network_interface.set_chain_position(node_id, &[]);
|
||||
}
|
||||
|
||||
pub fn insert_image_data(&mut self, resource_id: ResourceId, layer: LayerNodeIdentifier) {
|
||||
pub fn insert_image_data(&mut self, data: Arc<[u8]>, layer: LayerNodeIdentifier) {
|
||||
let transform = resolve_proto_node_type(graphene_std::transform_nodes::transform::IDENTIFIER)
|
||||
.expect("Transform node does not exist")
|
||||
.default_node_template();
|
||||
|
||||
let resource_id = ResourceId::new();
|
||||
self.responses.add(ResourceMessage::StoreEmbedded { resource_id, data });
|
||||
|
||||
let image_node = resolve_proto_node_type(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER)
|
||||
.expect("Image node does not exist")
|
||||
.node_template_input_override([None, Some(NodeInput::value(TaggedValue::Resource(resource_id), false))]);
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::messages::layout::utility_types::widget_prelude::*;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::{
|
||||
InputMetadata, NodeNetworkInterface, NodeNetworkTemplate, NodeTemplate, NodeTemplateImplementation, NodeTypePersistentMetadata, Vec2InputSettings, WidgetOverride,
|
||||
};
|
||||
use crate::messages::portfolio::resource_upload::utility_types::ResourceFileKind;
|
||||
use crate::messages::portfolio::ingest::utility_types::TypeFilter;
|
||||
use crate::messages::prelude::{DocumentId, FontsMessage, FontsMessageHandler, Message, ResourceMessageHandler, Responses};
|
||||
use crate::node_graph_executor::NodeGraphExecutor;
|
||||
use glam::DVec2;
|
||||
@@ -1466,7 +1466,7 @@ fn static_input_properties() -> InputProperties {
|
||||
map.insert(
|
||||
"image_file".to_string(),
|
||||
Box::new(|node_id, index, context| {
|
||||
let widgets = node_properties::resource_widget(ParameterWidgetsInfo::at_index(node_id, index, true, context), ResourceFileKind::RasterImage);
|
||||
let widgets = node_properties::resource_widget(ParameterWidgetsInfo::at_index(node_id, index, true, context), vec![TypeFilter::raster()]);
|
||||
Ok(vec![LayoutGroup::row(widgets)])
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions:
|
||||
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface};
|
||||
use crate::messages::portfolio::fonts::utility_types::FontCatalogStyle;
|
||||
use crate::messages::portfolio::resource_upload::utility_types::{ResourceFileKind, UploadTarget};
|
||||
use crate::messages::portfolio::ingest::utility_types::TypeFilter;
|
||||
use crate::messages::prelude::*;
|
||||
use crate::messages::tool::common_functionality::graph_modification_utils;
|
||||
use choice::enum_choice;
|
||||
@@ -342,7 +342,7 @@ pub(crate) fn property_from_type(
|
||||
Some(x) if id_is::<Footprint>(x) => footprint_widget(default_info, &mut extra_widgets),
|
||||
Some(x) if id_is::<Box<VectorModification>>(x) => vector_modification_widget(default_info).into(),
|
||||
Some(x) if id_is::<Image<Color>>(x) => image_data_widget(default_info).into(),
|
||||
Some(x) if id_is::<Resource>(x) => resource_widget(default_info, ResourceFileKind::Any).into(),
|
||||
Some(x) if id_is::<Resource>(x) => resource_widget(default_info, Vec::new()).into(),
|
||||
// ===============================
|
||||
// MANUALLY IMPLEMENTED ENUM TYPES
|
||||
// ===============================
|
||||
@@ -1290,8 +1290,8 @@ pub fn font_widget(parameter_widgets_info: ParameterWidgetsInfo) -> LayoutGroup
|
||||
font_widgets.into_iter().chain(style_widgets.unwrap_or_default()).collect::<Vec<_>>().into()
|
||||
}
|
||||
|
||||
/// A dropdown of the document's uploaded files, led by "None" and a "Browse…" entry that uploads another file of the given kind.
|
||||
pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, kind: ResourceFileKind) -> Vec<WidgetInstance> {
|
||||
/// A dropdown of the document's uploaded files, led by "None" and a "Browse…" entry that uploads another file matching the given filters.
|
||||
pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, filters: Vec<TypeFilter>) -> Vec<WidgetInstance> {
|
||||
let mut widgets = start_widgets(¶meter_widgets_info);
|
||||
|
||||
let Some(input) = parameter_widgets_info.input() else {
|
||||
@@ -1304,7 +1304,6 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, kind: Resou
|
||||
_ => return widgets,
|
||||
};
|
||||
|
||||
// Fonts have their own picker, so only uploaded files are listed, labeled by hash and user count until resources carry names
|
||||
let ParameterWidgetsInfo {
|
||||
document_id,
|
||||
node_id,
|
||||
@@ -1313,14 +1312,17 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, kind: Resou
|
||||
network_interface,
|
||||
..
|
||||
} = parameter_widgets_info;
|
||||
let user_counts = network_interface.resource_user_counts();
|
||||
let use_counts = network_interface.collect_resources_use_counts();
|
||||
|
||||
// This is a heuristic to filter for image resources and will break once other data types are loaded.
|
||||
// TODO: Add a proper way to filter for image resources.
|
||||
let mut files: Vec<(ResourceId, String, String)> = resources
|
||||
.registry
|
||||
.resolved()
|
||||
.filter(|info| !info.sources.iter().any(|source| matches!(source, DataSource::Font { .. })))
|
||||
.map(|info| {
|
||||
let hash = info.hash.map(|hash| hash.to_string()[..8].to_string()).unwrap_or_default();
|
||||
let users = user_counts.get(&info.id).copied().unwrap_or(0);
|
||||
let users = use_counts.get(&info.id).copied().unwrap_or(0);
|
||||
let tooltip_description = match users {
|
||||
0 => "Not used by any node input. This resource will be dropped upon document reload.".to_string(),
|
||||
users => format!("Used by {users} node input{}.", if users == 1 { "" } else { "s" }),
|
||||
@@ -1359,13 +1361,11 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, kind: Resou
|
||||
.tooltip_description("Pick a file from disk to use for this input.")
|
||||
.on_update(|_| Message::NoOp)
|
||||
.on_commit(move |_| {
|
||||
ResourceUploadMessage::RequestUpload {
|
||||
target: UploadTarget::NodeInput {
|
||||
document_id,
|
||||
node_id,
|
||||
input_index: index,
|
||||
kind,
|
||||
},
|
||||
IngestMessage::SetResourceInput {
|
||||
document_id,
|
||||
node_id,
|
||||
input_index: index,
|
||||
filters: filters.clone(),
|
||||
}
|
||||
.into()
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::messages::portfolio::document::document_message_handler::DocumentMess
|
||||
use crate::messages::portfolio::document::utility_types::misc::GroupFolderType;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{StorageMetadataView, build_interface_from_storage};
|
||||
use crate::messages::portfolio::resource_upload::utility_types::UploadTarget;
|
||||
use crate::messages::portfolio::ingest::utility_types::IngestAction;
|
||||
use crate::test_utils::test_prelude::*;
|
||||
use graphene_std::NodeParameter;
|
||||
use graphene_std::vector::style::RenderMode;
|
||||
@@ -491,22 +491,19 @@ async fn live_undo_new_document_draw_rect() {
|
||||
assert_eq!(editor.active_document().network_interface.document_network(), &before_rect, "undo should restore the pre-rect network");
|
||||
}
|
||||
|
||||
fn paste_named_image() -> IngestMessage {
|
||||
IngestMessage::Ingest {
|
||||
data: Image::new(2, 2, Color::WHITE).to_png(),
|
||||
action: IngestAction::Paste,
|
||||
mime_type: String::new(),
|
||||
path: Some("pasted.png".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pasting an image is one user action and must be one undo step: the paste handler brackets the layer add,
|
||||
/// name set, reparent, and transform in a single transaction. Were the name set to open its own nested
|
||||
/// transaction (a historical wart), the first undo would revert only the name and leave the layer behind, so
|
||||
/// this asserts the layer count returns to its pre-paste value after exactly one undo.
|
||||
/// Pastes a 2x2 image as a named layer.
|
||||
fn paste_named_image() -> ResourceUploadMessage {
|
||||
ResourceUploadMessage::Upload {
|
||||
name: Some("pasted".into()),
|
||||
data: Image::new(2, 2, Color::WHITE).to_png().into(),
|
||||
target: UploadTarget::Layer {
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paste_image_with_name_is_one_undo_step() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
@@ -534,6 +531,7 @@ async fn paste_image_with_name_is_one_undo_step() {
|
||||
/// Choosing "None" in the Image node's file picker leaves the empty-resource placeholder, which must still render.
|
||||
#[tokio::test]
|
||||
async fn image_node_with_no_file_still_evaluates() {
|
||||
use graph_craft::application_io::resource::Resource;
|
||||
use graph_craft::document::DocumentNodeImplementation;
|
||||
use graph_craft::document::value::TaggedValue;
|
||||
use graph_craft::item;
|
||||
@@ -555,7 +553,7 @@ async fn image_node_with_no_file_still_evaluates() {
|
||||
.handle_message(NodeGraphMessage::SetInputValue {
|
||||
node_id: image_node_id,
|
||||
input_index: graphene_std::raster_nodes::std_nodes::image::ResourceInput::INDEX,
|
||||
value: TaggedValue::TypeDefault(item!(graph_craft::application_io::resource::Resource)).into(),
|
||||
value: TaggedValue::TypeDefault(item!(Resource)).into(),
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -695,9 +693,11 @@ async fn demo_artwork_edit_autosaves_and_round_trips() {
|
||||
// Open a real demo artwork through the normal open path and let it render.
|
||||
let content = std::fs::read_to_string("../demo-artwork/changing-seasons.graphite").expect("read demo artwork");
|
||||
editor
|
||||
.handle_message(PortfolioMessage::OpenFile {
|
||||
path: "changing-seasons.graphite".into(),
|
||||
content: content.bytes().collect(),
|
||||
.handle_message(IngestMessage::Ingest {
|
||||
data: content.into_bytes(),
|
||||
action: IngestAction::Open,
|
||||
mime_type: String::new(),
|
||||
path: Some("changing-seasons.graphite".into()),
|
||||
})
|
||||
.await;
|
||||
|
||||
|
||||
@@ -311,11 +311,12 @@ impl NodeNetworkInterface {
|
||||
}
|
||||
|
||||
pub fn collect_used_resources(&self, target: &mut HashSet<ResourceId>) {
|
||||
collect_network_resources(self.document_network(), target);
|
||||
visit_network_resources(self.document_network(), &mut |id| {
|
||||
target.insert(id);
|
||||
});
|
||||
}
|
||||
|
||||
/// How many value inputs across the document reference each resource, with no entry for a resource nothing uses.
|
||||
pub fn resource_user_counts(&self) -> HashMap<ResourceId, usize> {
|
||||
pub fn collect_resources_use_counts(&self) -> HashMap<ResourceId, usize> {
|
||||
let mut counts = HashMap::new();
|
||||
visit_network_resources(self.document_network(), &mut |id| *counts.entry(id).or_insert(0) += 1);
|
||||
counts
|
||||
|
||||
@@ -283,12 +283,16 @@ impl NodeTemplateImplementation {
|
||||
/// Collects resource IDs referenced by a template and its nested networks.
|
||||
pub fn collect_template_resources(template: &NodeTemplate, out: &mut HashSet<ResourceId>) {
|
||||
for input in &template.inputs {
|
||||
collect_input_resource(input, out);
|
||||
visit_input_resource(input, &mut |id| {
|
||||
out.insert(id);
|
||||
});
|
||||
}
|
||||
|
||||
if let NodeTemplateImplementation::Network(network_template) = &template.implementation {
|
||||
for export in &network_template.exports {
|
||||
collect_input_resource(export, out);
|
||||
visit_input_resource(export, &mut |id| {
|
||||
out.insert(id);
|
||||
});
|
||||
}
|
||||
for nested_template in network_template.nodes.values() {
|
||||
collect_template_resources(nested_template, out);
|
||||
|
||||
@@ -865,27 +865,6 @@ pub(crate) enum SoleDependentStep {
|
||||
Escape,
|
||||
}
|
||||
|
||||
pub(crate) fn collect_network_resources(network: &NodeNetwork, out: &mut HashSet<ResourceId>) {
|
||||
visit_network_resources(network, &mut |id| {
|
||||
out.insert(id);
|
||||
});
|
||||
}
|
||||
|
||||
/// Collects resource IDs referenced by a node and its nested networks.
|
||||
pub fn collect_node_resources(node: &DocumentNode, out: &mut HashSet<ResourceId>) {
|
||||
visit_node_resources(node, &mut |id| {
|
||||
out.insert(id);
|
||||
});
|
||||
}
|
||||
|
||||
/// Records the resource ID held by a value input, covering node inputs and export slots alike.
|
||||
pub(crate) fn collect_input_resource(input: &NodeInput, out: &mut HashSet<ResourceId>) {
|
||||
visit_input_resource(input, &mut |id| {
|
||||
out.insert(id);
|
||||
});
|
||||
}
|
||||
|
||||
/// Calls `visit` once per value input holding a resource ID across a network and its nested networks.
|
||||
pub(crate) fn visit_network_resources(network: &NodeNetwork, visit: &mut impl FnMut(ResourceId)) {
|
||||
for export in &network.exports {
|
||||
visit_input_resource(export, visit);
|
||||
@@ -895,7 +874,7 @@ pub(crate) fn visit_network_resources(network: &NodeNetwork, visit: &mut impl Fn
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_node_resources(node: &DocumentNode, visit: &mut impl FnMut(ResourceId)) {
|
||||
pub(crate) fn visit_node_resources(node: &DocumentNode, visit: &mut impl FnMut(ResourceId)) {
|
||||
for input in &node.inputs {
|
||||
visit_input_resource(input, visit);
|
||||
}
|
||||
@@ -904,7 +883,7 @@ fn visit_node_resources(node: &DocumentNode, visit: &mut impl FnMut(ResourceId))
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_input_resource(input: &NodeInput, visit: &mut impl FnMut(ResourceId)) {
|
||||
pub(crate) fn visit_input_resource(input: &NodeInput, visit: &mut impl FnMut(ResourceId)) {
|
||||
if let NodeInput::Value { tagged_value, .. } = input
|
||||
&& let TaggedValue::Resource(id) = &**tagged_value
|
||||
{
|
||||
@@ -916,6 +895,38 @@ fn visit_input_resource(input: &NodeInput, visit: &mut impl FnMut(ResourceId)) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resource_visits_reach_nested_networks_and_exports() {
|
||||
let shared = ResourceId::from(1);
|
||||
let uses = |id: ResourceId| NodeInput::value(TaggedValue::Resource(id), false);
|
||||
let node = |inputs: Vec<NodeInput>| DocumentNode { inputs, ..Default::default() };
|
||||
let inner = NodeNetwork {
|
||||
exports: vec![uses(shared)],
|
||||
nodes: [(NodeId(2), node(vec![uses(shared)]))].into_iter().collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let outer = NodeNetwork {
|
||||
nodes: [
|
||||
(NodeId(1), node(vec![uses(shared), uses(ResourceId::from(2))])),
|
||||
(
|
||||
NodeId(3),
|
||||
DocumentNode {
|
||||
implementation: DocumentNodeImplementation::Network(inner),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut visits = Vec::new();
|
||||
visit_network_resources(&outer, &mut |id| visits.push(id));
|
||||
assert_eq!(visits.iter().filter(|id| **id == shared).count(), 3);
|
||||
assert_eq!(visits.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_click_targets_are_clickable_at_their_center() {
|
||||
let center = DVec2::new(100., 50.);
|
||||
@@ -930,31 +941,4 @@ mod tests {
|
||||
assert_eq!(ports.clicked_output_port_from_point(center + DVec2::new(200., 0.)), Some(0));
|
||||
assert_eq!(ports.clicked_output_port_from_point(center), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_visits_count_every_referencing_input_including_nested_networks() {
|
||||
let shared = ResourceId::from(1);
|
||||
let other = ResourceId::from(2);
|
||||
let uses = |id: ResourceId| NodeInput::value(TaggedValue::Resource(id), false);
|
||||
let node = |inputs: Vec<NodeInput>| DocumentNode { inputs, ..Default::default() };
|
||||
|
||||
let inner = NodeNetwork {
|
||||
exports: vec![uses(shared)],
|
||||
nodes: [(NodeId(2), node(vec![uses(shared)]))].into_iter().collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let nested = DocumentNode {
|
||||
implementation: DocumentNodeImplementation::Network(inner),
|
||||
..Default::default()
|
||||
};
|
||||
let outer = NodeNetwork {
|
||||
nodes: [(NodeId(1), node(vec![uses(shared), uses(other)])), (NodeId(3), nested)].into_iter().collect(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut counts = HashMap::new();
|
||||
visit_network_resources(&outer, &mut |id| *counts.entry(id).or_insert(0) += 1);
|
||||
assert_eq!(counts.get(&shared), Some(&3), "every referencing input counts, nested networks included");
|
||||
assert_eq!(counts.get(&other), Some(&1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use glam::{DVec2, IVec2};
|
||||
use graph_craft::application_io::resource::{DataSource, Resource, ResourceHash, ResourceId};
|
||||
use graph_craft::document::DocumentNode;
|
||||
use graph_craft::document::{DocumentNodeImplementation, NodeInput, value::TaggedValue};
|
||||
use graph_craft::{Type, item, list};
|
||||
use graph_craft::{Type, concrete, item, list};
|
||||
use graphene_std::Color;
|
||||
use graphene_std::ParameterRef;
|
||||
use graphene_std::ProtoNodeIdentifier;
|
||||
@@ -2877,6 +2877,24 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
|
||||
}
|
||||
}
|
||||
|
||||
// A `Resource` input disconnected before `Resource` became an item-ranked type default stored the bare `TypeDefault(Resource)`, which no longer unwraps to an `Item<Resource>` default
|
||||
if let Some(current_node) = document.network_interface.document_node(node_id, network_path) {
|
||||
let bare_resource_inputs: Vec<(usize, bool)> = current_node
|
||||
.inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, input)| match input {
|
||||
NodeInput::Value { tagged_value, exposed } if matches!(&**tagged_value, TaggedValue::TypeDefault(stored_type) if *stored_type == concrete!(Resource)) => Some((index, *exposed)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (index, exposed) in bare_resource_inputs {
|
||||
document
|
||||
.network_interface
|
||||
.set_input(&InputConnector::node_at_index(*node_id, index), NodeInput::type_default(item!(Resource), exposed), network_path);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================
|
||||
// PUT ALL MIGRATIONS ABOVE THIS LINE
|
||||
// ==================================
|
||||
@@ -3137,31 +3155,53 @@ mod tests {
|
||||
for legacy_input in legacy_inputs {
|
||||
let image_id = NodeId(1);
|
||||
let mut document = DocumentMessageHandler::default();
|
||||
let image_template = |inputs| NodeTemplate {
|
||||
let image_template = NodeTemplate {
|
||||
implementation: NodeTemplateImplementation::ProtoNode(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER),
|
||||
inputs,
|
||||
inputs: vec![NodeInput::value(TaggedValue::None, false)],
|
||||
..Default::default()
|
||||
};
|
||||
document.network_interface.insert_node(upstream_id, image_template(vec![]), &[]);
|
||||
document.network_interface.insert_node(image_id, image_template(vec![NodeInput::value(TaggedValue::None, false)]), &[]);
|
||||
document.network_interface.insert_node(upstream_id, NodeTemplate::default(), &[]);
|
||||
document.network_interface.insert_node(image_id, image_template, &[]);
|
||||
document.network_interface.set_input(&InputConnector::node_at_index(image_id, 0), legacy_input.clone(), &[]);
|
||||
|
||||
document_migration_upgrades(&mut document, false);
|
||||
|
||||
let image_node = &document.network_interface.document_network().nodes[&image_id];
|
||||
assert_eq!(image_node.inputs.len(), 2, "the image node should gain its placeholder primary input");
|
||||
match (&legacy_input, &image_node.inputs[1]) {
|
||||
(NodeInput::Node { .. }, migrated) => assert_eq!(migrated.as_node(), Some(upstream_id), "the wire should sit at input 1"),
|
||||
(_, migrated) => {
|
||||
let migrated = &image_node.inputs[1];
|
||||
match legacy_input.as_value() {
|
||||
None => assert_eq!(migrated.as_node(), Some(upstream_id), "the wire should sit at input 1"),
|
||||
Some(legacy_value) => {
|
||||
let Some(TaggedValue::Resource(stored)) = migrated.as_value() else {
|
||||
panic!("the file should sit at input 1")
|
||||
};
|
||||
assert!(document.resources.registry.contains(stored) || *stored == resource_id, "the stored file should be the legacy one");
|
||||
match legacy_value {
|
||||
TaggedValue::Resource(legacy) => assert_eq!(stored, legacy, "the stored resource should be kept"),
|
||||
_ => assert!(document.resources.registry.contains(stored), "the embedded pixels should be stored as a new resource"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_resource_type_defaults_become_item_defaults() {
|
||||
let node_id = NodeId(1);
|
||||
let mut document = DocumentMessageHandler::default();
|
||||
let image_template = NodeTemplate {
|
||||
implementation: NodeTemplateImplementation::ProtoNode(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER),
|
||||
inputs: vec![NodeInput::value(TaggedValue::None, false), NodeInput::value(TaggedValue::TypeDefault(concrete!(Resource)), true)],
|
||||
..Default::default()
|
||||
};
|
||||
document.network_interface.insert_node(node_id, image_template, &[]);
|
||||
|
||||
document_migration_upgrades(&mut document, false);
|
||||
|
||||
let migrated = &document.network_interface.document_network().nodes[&node_id].inputs[1];
|
||||
assert_eq!(migrated.as_value(), Some(&TaggedValue::TypeDefault(item!(Resource))), "the bare default should become the item default");
|
||||
assert!(matches!(migrated, NodeInput::Value { exposed: true, .. }), "the exposed flag should be kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_duplicate_node_replacements() {
|
||||
let mut hashmap = HashMap::<ProtoNodeIdentifier, u32>::new();
|
||||
|
||||
28
editor/src/messages/portfolio/ingest/ingest_message.rs
Normal file
28
editor/src/messages/portfolio/ingest/ingest_message.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use super::utility_types::{IngestAction, TypeFilter};
|
||||
use crate::messages::prelude::*;
|
||||
use graph_craft::document::NodeId;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[impl_message(Message, PortfolioMessage, Ingest)]
|
||||
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum IngestMessage {
|
||||
Ingest {
|
||||
data: Vec<u8>,
|
||||
action: IngestAction,
|
||||
mime_type: String,
|
||||
path: Option<PathBuf>,
|
||||
},
|
||||
Browse {
|
||||
filters: Vec<TypeFilter>,
|
||||
multiple: bool,
|
||||
action: IngestAction,
|
||||
},
|
||||
Open,
|
||||
Import,
|
||||
SetResourceInput {
|
||||
document_id: DocumentId,
|
||||
node_id: NodeId,
|
||||
input_index: usize,
|
||||
filters: Vec<TypeFilter>,
|
||||
},
|
||||
}
|
||||
317
editor/src/messages/portfolio/ingest/ingest_message_handler.rs
Normal file
317
editor/src/messages/portfolio/ingest/ingest_message_handler.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
use super::utility_types::{DataType, IngestAction, TypeFilter, decoded_image_size};
|
||||
use crate::messages::frontend::utility_types::{FileDialogOptions, FileFilter};
|
||||
use crate::messages::prelude::*;
|
||||
use glam::IVec2;
|
||||
use graph_craft::application_io::resource::ResourceId;
|
||||
use graph_craft::document::value::TaggedValue;
|
||||
|
||||
#[derive(ExtractField)]
|
||||
pub struct IngestMessageContext {
|
||||
pub document_open: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, ExtractField)]
|
||||
pub struct IngestMessageHandler {}
|
||||
|
||||
#[message_handler_data]
|
||||
impl MessageHandler<IngestMessage, IngestMessageContext> for IngestMessageHandler {
|
||||
fn process_message(&mut self, message: IngestMessage, responses: &mut VecDeque<Message>, context: IngestMessageContext) {
|
||||
match message {
|
||||
IngestMessage::Open => responses.add(IngestMessage::Browse {
|
||||
filters: vec![TypeFilter::documents(), TypeFilter::image()],
|
||||
multiple: true,
|
||||
action: IngestAction::Open,
|
||||
}),
|
||||
IngestMessage::Import => responses.add(IngestMessage::Browse {
|
||||
filters: vec![TypeFilter::image()],
|
||||
multiple: false,
|
||||
action: IngestAction::Import,
|
||||
}),
|
||||
IngestMessage::SetResourceInput {
|
||||
document_id,
|
||||
node_id,
|
||||
input_index,
|
||||
filters,
|
||||
} => {
|
||||
let accepted_types = filters.iter().flat_map(|filter| filter.types.iter().copied()).collect();
|
||||
|
||||
responses.add(IngestMessage::Browse {
|
||||
filters,
|
||||
multiple: false,
|
||||
action: IngestAction::ResourceInput {
|
||||
document_id,
|
||||
node_id,
|
||||
input_index: input_index as u32,
|
||||
accepted_types,
|
||||
},
|
||||
});
|
||||
}
|
||||
IngestMessage::Browse { filters, multiple, action } => responses.add(FrontendMessage::TriggerBrowse {
|
||||
options: FileDialogOptions {
|
||||
filters: filters.into_iter().map(FileFilter::from).collect(),
|
||||
multiple,
|
||||
},
|
||||
action,
|
||||
}),
|
||||
IngestMessage::Ingest { data, action, mime_type, path } => {
|
||||
let data_type = DataType::detect(&data, &mime_type, path.as_deref());
|
||||
|
||||
let placement = match action {
|
||||
IngestAction::ResourceInput {
|
||||
document_id,
|
||||
node_id,
|
||||
input_index,
|
||||
accepted_types,
|
||||
} => {
|
||||
if !is_accepted(&data, data_type, &accepted_types) {
|
||||
responses.add(DialogMessage::DisplayDialogError {
|
||||
title: "Unsupported file".into(),
|
||||
description: "This file is not a type that this input accepts.".into(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// The file goes to the document that asked for it, which may no longer be active or open once the dialog closes
|
||||
let resource_id = ResourceId::new();
|
||||
let messages = [
|
||||
DocumentMessage::AddTransaction,
|
||||
DocumentMessage::Resource(ResourceMessage::StoreEmbedded { resource_id, data: data.into() }),
|
||||
DocumentMessage::NodeGraph(NodeGraphMessage::SetInputValue {
|
||||
node_id,
|
||||
input_index: input_index as usize,
|
||||
value: TaggedValue::Resource(resource_id).into(),
|
||||
}),
|
||||
];
|
||||
for message in messages {
|
||||
responses.add(PortfolioMessage::DocumentPassMessage { document_id, message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
IngestAction::Open => None,
|
||||
IngestAction::Import | IngestAction::Paste => Some((None, None)),
|
||||
IngestAction::DropOnCanvas { mouse } => Some((Some(mouse), None)),
|
||||
IngestAction::DropOnLayers { parent, insert_index } => Some((None, Some((parent, insert_index as usize)))),
|
||||
}
|
||||
.filter(|_| context.document_open);
|
||||
|
||||
let name = path.as_ref().and_then(|path| path.file_stem()).map(|stem| stem.to_string_lossy().into_owned());
|
||||
let document_path = path.filter(|path| path.is_absolute());
|
||||
|
||||
let (mouse, parent_and_insert_index) = placement.unwrap_or_default();
|
||||
let place_at_origin = placement.is_none();
|
||||
let (insert, artboard_canvas) = match data_type {
|
||||
DataType::GraphiteLegacy => {
|
||||
let Ok(document_serialized_content) = String::from_utf8(data) else {
|
||||
return unsupported(responses);
|
||||
};
|
||||
responses.add(PortfolioMessage::OpenDocumentFile {
|
||||
document_name: name,
|
||||
document_path,
|
||||
document_serialized_content,
|
||||
});
|
||||
return;
|
||||
}
|
||||
DataType::Gdd => {
|
||||
responses.add(PortfolioMessage::OpenGddDocument {
|
||||
document_name: name,
|
||||
document_path,
|
||||
content: data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
DataType::Svg => {
|
||||
let Ok(svg) = String::from_utf8(data) else { return unsupported(responses) };
|
||||
let artboard_canvas = place_at_origin.then(|| svg_canvas(&svg)).flatten();
|
||||
let insert = DocumentMessage::InsertSvg {
|
||||
name: name.clone(),
|
||||
svg,
|
||||
mouse,
|
||||
parent_and_insert_index,
|
||||
place_at_origin,
|
||||
};
|
||||
(insert, artboard_canvas)
|
||||
}
|
||||
DataType::Raster(_) => {
|
||||
let Some(size) = decoded_image_size(&data) else { return unsupported(responses) };
|
||||
let insert = DocumentMessage::InsertImage {
|
||||
name: name.clone(),
|
||||
data: data.into(),
|
||||
size: size.into(),
|
||||
mouse,
|
||||
parent_and_insert_index,
|
||||
place_at_origin,
|
||||
};
|
||||
(insert, None)
|
||||
}
|
||||
DataType::Unknown => return unsupported(responses),
|
||||
};
|
||||
|
||||
if !place_at_origin {
|
||||
responses.add(insert);
|
||||
return;
|
||||
}
|
||||
responses.add(PortfolioMessage::NewDocumentWithName { name: name.unwrap_or_default() });
|
||||
responses.add(insert);
|
||||
responses.add(DeferMessage::AfterGraphRun {
|
||||
messages: vec![
|
||||
DocumentMessage::WrapContentInArtboard {
|
||||
place_artboard_at_origin: true,
|
||||
artboard_canvas,
|
||||
}
|
||||
.into(),
|
||||
],
|
||||
});
|
||||
responses.add(DeferMessage::AfterNavigationReady {
|
||||
messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn actions(&self) -> ActionList {
|
||||
actions!(IngestMessageDiscriminant;)
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported(responses: &mut VecDeque<Message>) {
|
||||
responses.add(DialogMessage::DisplayDialogError {
|
||||
title: "Unsupported format".into(),
|
||||
description: "This file is not a supported document or image format.".into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a node input takes the file, where an image must also fully decode.
|
||||
fn is_accepted(data: &[u8], data_type: DataType, accepted_types: &[DataType]) -> bool {
|
||||
if accepted_types.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
accepted_types.contains(&data_type) && (!matches!(data_type, DataType::Raster(_)) || decoded_image_size(data).is_some())
|
||||
}
|
||||
|
||||
// The viewBox preserves the full canvas rather than the tighter bounding box of the rendered content
|
||||
fn svg_canvas(svg: &str) -> Option<(IVec2, IVec2)> {
|
||||
usvg::roxmltree::Document::parse(svg)
|
||||
.ok()
|
||||
.and_then(|document| {
|
||||
let numbers: Vec<f64> = document
|
||||
.root_element()
|
||||
.attribute("viewBox")?
|
||||
.split(|character: char| character.is_ascii_whitespace() || character == ',')
|
||||
.filter_map(|number| number.parse().ok())
|
||||
.collect();
|
||||
let [x, y, width, height, ..] = numbers[..] else { return None };
|
||||
Some((IVec2::new(x.round() as i32, y.round() as i32), IVec2::new(width.round() as i32, height.round() as i32)))
|
||||
})
|
||||
.or_else(|| {
|
||||
let size = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?.size();
|
||||
Some((IVec2::ZERO, IVec2::new(size.width().round() as i32, size.height().round() as i32)))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use graph_craft::document::NodeId;
|
||||
use graphene_std::Color;
|
||||
use graphene_std::raster::Image;
|
||||
|
||||
const REQUESTING_DOCUMENT: DocumentId = DocumentId(3);
|
||||
|
||||
fn ingest(data: &[u8], action: IngestAction, document_open: bool) -> VecDeque<Message> {
|
||||
let mut responses = VecDeque::new();
|
||||
let message = IngestMessage::Ingest {
|
||||
data: data.into(),
|
||||
action,
|
||||
mime_type: String::new(),
|
||||
path: None,
|
||||
};
|
||||
IngestMessageHandler::default().process_message(message, &mut responses, IngestMessageContext { document_open });
|
||||
responses
|
||||
}
|
||||
|
||||
fn resource_input(accepted_types: Vec<DataType>) -> IngestAction {
|
||||
IngestAction::ResourceInput {
|
||||
document_id: REQUESTING_DOCUMENT,
|
||||
node_id: NodeId(7),
|
||||
input_index: 1,
|
||||
accepted_types,
|
||||
}
|
||||
}
|
||||
|
||||
fn stored_resource(message: &Message) -> Option<ResourceId> {
|
||||
match message {
|
||||
Message::Portfolio(PortfolioMessage::DocumentPassMessage {
|
||||
document_id: REQUESTING_DOCUMENT,
|
||||
message: DocumentMessage::Resource(ResourceMessage::StoreEmbedded { resource_id, .. }),
|
||||
}) => Some(*resource_id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_input_stores_the_file_and_assigns_it_in_the_requesting_document() {
|
||||
let responses = ingest(b"any bytes", resource_input(Vec::new()), true);
|
||||
let assigned = responses.iter().find_map(|message| match message {
|
||||
Message::Portfolio(PortfolioMessage::DocumentPassMessage {
|
||||
document_id: REQUESTING_DOCUMENT,
|
||||
message: DocumentMessage::NodeGraph(NodeGraphMessage::SetInputValue { node_id, input_index, value }),
|
||||
}) => Some((*node_id, *input_index, value.clone())),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let stored = responses.iter().find_map(stored_resource).expect("the file should be stored as a resource");
|
||||
assert_eq!(assigned, Some((NodeId(7), 1, TaggedValue::Resource(stored).into())));
|
||||
assert!(
|
||||
responses.iter().all(|message| matches!(message, Message::Portfolio(PortfolioMessage::DocumentPassMessage { .. }))),
|
||||
"nothing should reach whichever document happens to be active"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_input_rejects_a_file_outside_its_types_before_storing_it() {
|
||||
let png = Image::new(8, 8, Color::WHITE).to_png();
|
||||
let raster = TypeFilter::raster().types;
|
||||
assert!(ingest(&png, resource_input(raster.clone()), true).iter().any(|message| stored_resource(message).is_some()));
|
||||
|
||||
// Text, an SVG, and an image cut off after its intact header
|
||||
for rejected in [b"not an image".as_slice(), b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>".as_slice(), &png[..40]] {
|
||||
let responses = ingest(rejected, resource_input(raster.clone()), true);
|
||||
assert_eq!(responses.len(), 1, "a rejected file should not become a resource");
|
||||
assert!(matches!(responses[0], Message::Dialog(DialogMessage::DisplayDialogError { .. })), "the user should be told why");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_image_only_shows_a_dialog() {
|
||||
let png = Image::new(8, 8, Color::WHITE).to_png();
|
||||
let responses = ingest(&png[..40], IngestAction::Paste, true);
|
||||
assert_eq!(responses.len(), 1);
|
||||
assert!(matches!(responses[0], Message::Dialog(DialogMessage::DisplayDialogError { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_file_only_shows_a_dialog() {
|
||||
let responses = ingest(b"just text", IngestAction::Open, true);
|
||||
assert_eq!(responses.len(), 1);
|
||||
assert!(matches!(responses[0], Message::Dialog(DialogMessage::DisplayDialogError { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_without_a_document_opens_one() {
|
||||
let png = Image::new(1, 1, Color::WHITE).to_png();
|
||||
let responses = ingest(&png, IngestAction::Paste, false);
|
||||
assert!(matches!(responses[0], Message::Portfolio(PortfolioMessage::NewDocumentWithName { .. })));
|
||||
assert!(
|
||||
responses
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::Portfolio(PortfolioMessage::Document(DocumentMessage::InsertImage { place_at_origin: true, .. }))))
|
||||
);
|
||||
|
||||
let responses = ingest(&png, IngestAction::Paste, true);
|
||||
assert!(matches!(
|
||||
responses[0],
|
||||
Message::Portfolio(PortfolioMessage::Document(DocumentMessage::InsertImage { place_at_origin: false, .. }))
|
||||
));
|
||||
}
|
||||
}
|
||||
9
editor/src/messages/portfolio/ingest/mod.rs
Normal file
9
editor/src/messages/portfolio/ingest/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod ingest_message;
|
||||
mod ingest_message_handler;
|
||||
|
||||
pub mod utility_types;
|
||||
|
||||
#[doc(inline)]
|
||||
pub use ingest_message::{IngestMessage, IngestMessageDiscriminant};
|
||||
#[doc(inline)]
|
||||
pub use ingest_message_handler::{IngestMessageContext, IngestMessageHandler};
|
||||
221
editor/src/messages/portfolio/ingest/utility_types.rs
Normal file
221
editor/src/messages/portfolio/ingest/utility_types.rs
Normal file
@@ -0,0 +1,221 @@
|
||||
use crate::consts::{FILE_EXTENSION, GDD_FILE_EXTENSION};
|
||||
use crate::messages::frontend::utility_types::FileFilter;
|
||||
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use crate::messages::prelude::DocumentId;
|
||||
use document_container::archive::ArchiveFormat;
|
||||
use graph_craft::document::NodeId;
|
||||
use image::ImageFormat;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
|
||||
/// How many leading bytes are inspected to recognize a text format.
|
||||
const SNIFFED_TEXT_LENGTH: usize = 4096;
|
||||
|
||||
/// The pixel size of a file that fully decodes as a raster image.
|
||||
pub fn decoded_image_size(data: &[u8]) -> Option<(u32, u32)> {
|
||||
image::load_from_memory(data).ok().map(|image| (image.width(), image.height()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
|
||||
pub enum IngestAction {
|
||||
Open,
|
||||
Import,
|
||||
Paste,
|
||||
DropOnCanvas {
|
||||
mouse: (f64, f64),
|
||||
},
|
||||
DropOnLayers {
|
||||
parent: LayerNodeIdentifier,
|
||||
insert_index: u32,
|
||||
},
|
||||
ResourceInput {
|
||||
document_id: DocumentId,
|
||||
node_id: NodeId,
|
||||
input_index: u32,
|
||||
/// The types this input takes, where none means any file.
|
||||
#[cfg_attr(feature = "wasm", tsify(type = "unknown"))]
|
||||
accepted_types: Vec<DataType>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum DataType {
|
||||
GraphiteLegacy,
|
||||
Gdd,
|
||||
Svg,
|
||||
Raster(ImageFormat),
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl DataType {
|
||||
/// The content decides, and the MIME type then the file name only settle what it leaves unknown.
|
||||
pub fn detect(data: &[u8], mime_type: &str, path: Option<&Path>) -> Self {
|
||||
match Self::from_content(data) {
|
||||
Self::Unknown => match Self::from_mime(mime_type) {
|
||||
Self::Unknown => path.map_or(Self::Unknown, Self::from_path),
|
||||
data_type => data_type,
|
||||
},
|
||||
data_type => data_type,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_mime(mime: &str) -> Self {
|
||||
match mime.to_ascii_lowercase().as_str() {
|
||||
"application/graphite+json" => Self::GraphiteLegacy,
|
||||
"application/vnd.graphite.document" => Self::Gdd,
|
||||
"image/svg+xml" => Self::Svg,
|
||||
mime => ImageFormat::from_mime_type(mime).map_or(Self::Unknown, Self::Raster),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_extension(extension: &str) -> Self {
|
||||
match extension.trim_start_matches('.').to_ascii_lowercase().as_str() {
|
||||
FILE_EXTENSION => Self::GraphiteLegacy,
|
||||
GDD_FILE_EXTENSION => Self::Gdd,
|
||||
"svg" => Self::Svg,
|
||||
extension => ImageFormat::from_extension(extension).map_or(Self::Unknown, Self::Raster),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_path(path: impl AsRef<Path>) -> Self {
|
||||
path.as_ref().extension().and_then(OsStr::to_str).map_or(Self::Unknown, Self::from_extension)
|
||||
}
|
||||
|
||||
pub fn from_content(data: &[u8]) -> Self {
|
||||
if ArchiveFormat::detect(data).is_some() {
|
||||
return Self::Gdd;
|
||||
}
|
||||
if let Ok(format) = image::guess_format(data) {
|
||||
return Self::Raster(format);
|
||||
}
|
||||
|
||||
// Only the head is read as text, where a character split by the cut is the one invalid sequence tolerated
|
||||
let head = &data[..data.len().min(SNIFFED_TEXT_LENGTH)];
|
||||
let text = match std::str::from_utf8(head) {
|
||||
Ok(text) => text,
|
||||
Err(error) if error.error_len().is_none() => std::str::from_utf8(&head[..error.valid_up_to()]).unwrap_or_default(),
|
||||
Err(_) => return Self::Unknown,
|
||||
};
|
||||
|
||||
let text = text.trim_start_matches('\u{feff}').trim_start();
|
||||
if text.starts_with('{') {
|
||||
Self::GraphiteLegacy
|
||||
} else if text.starts_with('<') && text.contains("<svg") {
|
||||
Self::Svg
|
||||
} else {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mime(self) -> Option<&'static str> {
|
||||
Some(match self {
|
||||
Self::GraphiteLegacy => "application/graphite+json",
|
||||
Self::Gdd => "application/vnd.graphite.document",
|
||||
Self::Svg => "image/svg+xml",
|
||||
Self::Raster(format) => format.to_mime_type(),
|
||||
Self::Unknown => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extensions(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::GraphiteLegacy => &[FILE_EXTENSION],
|
||||
Self::Gdd => &[GDD_FILE_EXTENSION],
|
||||
Self::Svg => &["svg"],
|
||||
Self::Raster(format) => format.extensions_str(),
|
||||
Self::Unknown => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TypeFilter {
|
||||
pub name: String,
|
||||
pub types: Vec<DataType>,
|
||||
}
|
||||
|
||||
impl TypeFilter {
|
||||
pub fn documents() -> Self {
|
||||
Self {
|
||||
name: "Graphite Document".into(),
|
||||
types: vec![DataType::Gdd, DataType::GraphiteLegacy],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn raster() -> Self {
|
||||
Self {
|
||||
name: "Image".into(),
|
||||
types: ImageFormat::all().filter(ImageFormat::reading_enabled).map(DataType::Raster).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn image() -> Self {
|
||||
let mut images = Self::raster();
|
||||
images.types.push(DataType::Svg);
|
||||
images
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TypeFilter> for FileFilter {
|
||||
fn from(filter: TypeFilter) -> Self {
|
||||
Self {
|
||||
name: filter.name,
|
||||
extensions: filter.types.iter().flat_map(|data_type| data_type.extensions()).map(|extension| extension.to_string()).collect(),
|
||||
mime_types: filter.types.iter().filter_map(|data_type| data_type.mime()).map(str::to_string).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use graphene_std::Color;
|
||||
use graphene_std::raster::Image;
|
||||
|
||||
#[test]
|
||||
fn data_type_from_content() {
|
||||
assert_eq!(DataType::from_content(&Image::new(1, 1, Color::WHITE).to_png()), DataType::Raster(ImageFormat::Png));
|
||||
assert_eq!(DataType::from_content(b"PK\x03\x04"), DataType::Gdd);
|
||||
assert_eq!(DataType::from_content(b"{\"network_interface\":{}}"), DataType::GraphiteLegacy);
|
||||
assert_eq!(
|
||||
DataType::from_content("\u{feff}<?xml version=\"1.0\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\"/>".as_bytes()),
|
||||
DataType::Svg
|
||||
);
|
||||
assert_eq!(DataType::from_content(b"just text"), DataType::Unknown);
|
||||
assert_eq!(DataType::from_content(&[0xff, 0xfe, 0x00]), DataType::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_type_from_content_reads_only_the_head() {
|
||||
let late_svg = format!("<!--{}--><svg/>", " ".repeat(SNIFFED_TEXT_LENGTH));
|
||||
assert_eq!(DataType::from_content(late_svg.as_bytes()), DataType::Unknown);
|
||||
|
||||
// The three-byte euro sign straddles the cut, which must not hide the text before it
|
||||
let straddling = format!("<svg>{}€", " ".repeat(SNIFFED_TEXT_LENGTH - 6));
|
||||
assert_eq!(DataType::from_content(straddling.as_bytes()), DataType::Svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_type_detect() {
|
||||
let detect = |mime_type: &str, path: &str| DataType::detect(&[], mime_type, Some(Path::new(path)));
|
||||
assert_eq!(detect("", "photo.JPEG"), DataType::Raster(ImageFormat::Jpeg));
|
||||
assert_eq!(detect("image/svg+xml", ""), DataType::Svg);
|
||||
assert_eq!(detect("application/graphite+json", ""), DataType::GraphiteLegacy);
|
||||
assert_eq!(detect("image/png", "document.gdd"), DataType::Raster(ImageFormat::Png));
|
||||
assert_eq!(detect("text/csv", "table.csv"), DataType::Unknown);
|
||||
assert_eq!(DataType::detect(&[], "", None), DataType::Unknown);
|
||||
|
||||
let png = Image::new(1, 1, Color::WHITE).to_png();
|
||||
assert_eq!(DataType::detect(&png, "image/svg+xml", Some(Path::new("drawing.svg"))), DataType::Raster(ImageFormat::Png));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_filter_to_file_filter() {
|
||||
let filter = FileFilter::from(TypeFilter::image());
|
||||
assert!(filter.extensions.iter().any(|extension| extension == "jpeg") && filter.extensions.iter().any(|extension| extension == "png"));
|
||||
assert!(filter.extensions.last().is_some_and(|extension| extension == "svg"));
|
||||
assert!(filter.mime_types.contains(&"image/jpeg".to_string()) && filter.mime_types.contains(&"image/svg+xml".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,17 @@ pub mod document;
|
||||
pub mod document_migration;
|
||||
pub mod document_storage_io;
|
||||
pub mod fonts;
|
||||
pub mod ingest;
|
||||
pub mod persistent_state;
|
||||
pub mod resource_upload;
|
||||
pub mod utility_types;
|
||||
|
||||
#[doc(inline)]
|
||||
pub use fonts::{FontsMessage, FontsMessageContext, FontsMessageHandler};
|
||||
#[doc(inline)]
|
||||
pub use ingest::{IngestMessage, IngestMessageContext, IngestMessageHandler};
|
||||
#[doc(inline)]
|
||||
pub use persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageHandler};
|
||||
#[doc(inline)]
|
||||
pub use portfolio_message::{PortfolioMessage, PortfolioMessageDiscriminant};
|
||||
#[doc(inline)]
|
||||
pub use portfolio_message_handler::{PortfolioMessageContext, PortfolioMessageHandler};
|
||||
#[doc(inline)]
|
||||
pub use resource_upload::{ResourceUploadMessage, ResourceUploadMessageContext, ResourceUploadMessageHandler};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use super::persistent_state::PersistentStateMessage;
|
||||
use super::resource_upload::ResourceUploadMessage;
|
||||
use super::utility_types::{DockingSplitDirection, PanelGroupId, PanelType};
|
||||
use crate::messages::frontend::utility_types::{ExportBounds, FileType, PersistedState};
|
||||
use crate::messages::prelude::*;
|
||||
@@ -16,9 +15,9 @@ pub enum PortfolioMessage {
|
||||
#[child]
|
||||
Fonts(FontsMessage),
|
||||
#[child]
|
||||
PersistentState(PersistentStateMessage),
|
||||
Ingest(IngestMessage),
|
||||
#[child]
|
||||
ResourceUpload(ResourceUploadMessage),
|
||||
PersistentState(PersistentStateMessage),
|
||||
|
||||
// Messages
|
||||
Init,
|
||||
@@ -89,16 +88,6 @@ pub enum PortfolioMessage {
|
||||
name: String,
|
||||
},
|
||||
NextDocument,
|
||||
Open,
|
||||
Import,
|
||||
OpenFile {
|
||||
path: PathBuf,
|
||||
content: Vec<u8>,
|
||||
},
|
||||
ImportFile {
|
||||
path: PathBuf,
|
||||
content: Vec<u8>,
|
||||
},
|
||||
OpenDocumentFile {
|
||||
document_name: Option<String>,
|
||||
document_path: Option<PathBuf>,
|
||||
@@ -139,16 +128,6 @@ pub enum PortfolioMessage {
|
||||
document_is_saved: bool,
|
||||
document_serialized_content: String,
|
||||
},
|
||||
OpenSvg {
|
||||
name: Option<String>,
|
||||
svg: String,
|
||||
},
|
||||
InsertSvg {
|
||||
name: Option<String>,
|
||||
svg: String,
|
||||
mouse: Option<(f64, f64)>,
|
||||
parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>,
|
||||
},
|
||||
CenterLayers {
|
||||
layers: Vec<LayerNodeIdentifier>,
|
||||
},
|
||||
|
||||
@@ -15,8 +15,6 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::OutputConnector;
|
||||
use crate::messages::portfolio::document_migration::*;
|
||||
use crate::messages::portfolio::document_storage_io::{build_or_open_working_copy, compare_storage_against_runtime, open_gdd_document};
|
||||
use crate::messages::portfolio::resource_upload::utility_types::{UploadTarget, image_file_filter};
|
||||
use crate::messages::portfolio::utility_types::FileContent;
|
||||
use crate::messages::preferences::SelectionMode;
|
||||
use crate::messages::prelude::*;
|
||||
use crate::messages::tool::utility_types::{HintData, ToolType};
|
||||
@@ -26,7 +24,7 @@ use glam::{DAffine2, DVec2};
|
||||
use graph_craft::application_io::resource::{DataSource, ResourceHash};
|
||||
use graph_craft::document::NodeId;
|
||||
use graphene_std::renderer::Quad;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::vec;
|
||||
|
||||
@@ -60,7 +58,7 @@ pub struct PortfolioMessageHandler {
|
||||
pub(crate) active_document_id: Option<DocumentId>,
|
||||
persistent_state: PersistentStateMessageHandler,
|
||||
pub fonts: FontsMessageHandler,
|
||||
resource_upload: ResourceUploadMessageHandler,
|
||||
ingest: IngestMessageHandler,
|
||||
pub executor: NodeGraphExecutor,
|
||||
pub selection_mode: SelectionMode,
|
||||
pub reset_node_definitions_on_open: bool,
|
||||
@@ -116,11 +114,11 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
let context = FontsMessageContext { resource_storage };
|
||||
self.fonts.process_message(message, responses, context);
|
||||
}
|
||||
PortfolioMessage::ResourceUpload(message) => {
|
||||
let context = ResourceUploadMessageContext {
|
||||
PortfolioMessage::Ingest(message) => {
|
||||
let context = IngestMessageContext {
|
||||
document_open: self.active_document().is_some(),
|
||||
};
|
||||
self.resource_upload.process_message(message, responses, context);
|
||||
self.ingest.process_message(message, responses, context);
|
||||
}
|
||||
|
||||
// Messages
|
||||
@@ -510,6 +508,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
filters: vec![FileFilter {
|
||||
name: "Graphite Document".into(),
|
||||
extensions: vec![FILE_EXTENSION.into()],
|
||||
mime_types: Vec::new(),
|
||||
}],
|
||||
content: serde_bytes::ByteBuf::from(content),
|
||||
});
|
||||
@@ -521,6 +520,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
filters: vec![FileFilter {
|
||||
name: "Zip Archive".into(),
|
||||
extensions: vec!["zip".into()],
|
||||
mime_types: Vec::new(),
|
||||
}],
|
||||
content: serde_bytes::ByteBuf::from(zip_bytes),
|
||||
}),
|
||||
@@ -664,107 +664,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
responses.add(PortfolioMessage::SelectDocument { document_id: next_id });
|
||||
}
|
||||
}
|
||||
PortfolioMessage::Open => {
|
||||
// This portfolio message wraps the frontend message so it can be listed as an action, which isn't possible for frontend messages
|
||||
responses.add(FrontendMessage::TriggerOpen {
|
||||
filters: vec![
|
||||
FileFilter {
|
||||
name: "Graphite Document".into(),
|
||||
extensions: vec![FILE_EXTENSION.into(), GDD_FILE_EXTENSION.into()],
|
||||
},
|
||||
image_file_filter(),
|
||||
],
|
||||
});
|
||||
}
|
||||
PortfolioMessage::Import => {
|
||||
// This portfolio message wraps the frontend message so it can be listed as an action, which isn't possible for frontend messages
|
||||
// TODO: Also offer the Graphite document filter once importing Graphite documents as nodes is supported
|
||||
responses.add(FrontendMessage::TriggerImport { filters: vec![image_file_filter()] });
|
||||
}
|
||||
PortfolioMessage::OpenFile { path, content } => {
|
||||
let name = path.file_stem().map(|n| n.to_string_lossy().to_string());
|
||||
match Self::read_file(&path, content) {
|
||||
FileContent::Document(content) => {
|
||||
let document_path = if path.is_absolute() { Some(path) } else { None };
|
||||
responses.add(PortfolioMessage::OpenDocumentFile {
|
||||
document_name: name,
|
||||
document_path,
|
||||
document_serialized_content: content,
|
||||
});
|
||||
}
|
||||
FileContent::GddDocument(content) => {
|
||||
let document_path = if path.is_absolute() { Some(path) } else { None };
|
||||
responses.add(PortfolioMessage::OpenGddDocument {
|
||||
document_name: name,
|
||||
document_path,
|
||||
content,
|
||||
});
|
||||
}
|
||||
FileContent::Svg(svg) => {
|
||||
responses.add(PortfolioMessage::OpenSvg { name, svg });
|
||||
}
|
||||
FileContent::Image(data) => {
|
||||
responses.add(ResourceUploadMessage::Upload {
|
||||
name,
|
||||
data: data.into(),
|
||||
target: UploadTarget::Document,
|
||||
});
|
||||
}
|
||||
FileContent::Unsupported => {
|
||||
// TODO: Show a more thoughtfully designed error message to the user
|
||||
responses.add(DialogMessage::DisplayDialogError {
|
||||
title: "Unsupported format".into(),
|
||||
description: "This file cannot be opened because it is not a supported image file type.".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
PortfolioMessage::ImportFile { path, content } => {
|
||||
let name = path.file_stem().map(|n| n.to_string_lossy().to_string());
|
||||
match Self::read_file(&path, content) {
|
||||
FileContent::Document(content) => {
|
||||
// TODO: Consider importing a document as a node into the current document
|
||||
// For now treat importing a document as opening it
|
||||
responses.add(PortfolioMessage::OpenDocumentFile {
|
||||
document_name: name,
|
||||
document_path: Some(path),
|
||||
document_serialized_content: content,
|
||||
});
|
||||
}
|
||||
FileContent::GddDocument(content) => {
|
||||
responses.add(PortfolioMessage::OpenGddDocument {
|
||||
document_name: name,
|
||||
document_path: Some(path),
|
||||
content,
|
||||
});
|
||||
}
|
||||
FileContent::Svg(svg) => {
|
||||
responses.add(PortfolioMessage::InsertSvg {
|
||||
name,
|
||||
svg,
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
});
|
||||
}
|
||||
FileContent::Image(data) => {
|
||||
responses.add(ResourceUploadMessage::Upload {
|
||||
name,
|
||||
data: data.into(),
|
||||
target: UploadTarget::Layer {
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
FileContent::Unsupported => {
|
||||
// TODO: Show a more thoughtfully designed error message to the user
|
||||
responses.add(DialogMessage::DisplayDialogError {
|
||||
title: "Unsupported format".into(),
|
||||
description: "This file cannot be imported because it is not a supported image file type.".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
PortfolioMessage::OpenDocumentFile {
|
||||
document_name,
|
||||
document_path,
|
||||
@@ -997,61 +896,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
self.tick_autosave_load_progress(responses, false);
|
||||
}
|
||||
}
|
||||
PortfolioMessage::OpenSvg { name, svg } => {
|
||||
responses.add(PortfolioMessage::NewDocumentWithName {
|
||||
name: name.clone().unwrap_or_default(),
|
||||
});
|
||||
|
||||
// Parse the SVG to extract its declared canvas origin and dimensions from the viewBox attribute.
|
||||
// This preserves the full canvas rather than measuring only the tighter rendered content bounding box.
|
||||
let artboard_canvas = usvg::roxmltree::Document::parse(&svg)
|
||||
.ok()
|
||||
.and_then(|doc| {
|
||||
let vb = doc.root_element().attribute("viewBox")?;
|
||||
let nums: Vec<f64> = vb
|
||||
.split(|c: char| c.is_ascii_whitespace() || c == ',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
if nums.len() >= 4 {
|
||||
Some((
|
||||
glam::IVec2::new(nums[0].round() as i32, nums[1].round() as i32),
|
||||
glam::IVec2::new(nums[2].round() as i32, nums[3].round() as i32),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fall back to the viewport size when there is no viewBox attribute
|
||||
usvg::Tree::from_str(&svg, &usvg::Options::default()).ok().map(|tree| {
|
||||
let size = tree.size();
|
||||
(glam::IVec2::ZERO, glam::IVec2::new(size.width().round() as i32, size.height().round() as i32))
|
||||
})
|
||||
});
|
||||
|
||||
responses.add(DocumentMessage::InsertSvg {
|
||||
name,
|
||||
svg,
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
place_at_origin: true,
|
||||
});
|
||||
|
||||
// Wait for the document to be rendered so the click targets can be calculated in order to determine the artboard size that will encompass the pasted SVG
|
||||
responses.add(DeferMessage::AfterGraphRun {
|
||||
messages: vec![
|
||||
DocumentMessage::WrapContentInArtboard {
|
||||
place_artboard_at_origin: true,
|
||||
artboard_canvas,
|
||||
}
|
||||
.into(),
|
||||
],
|
||||
});
|
||||
responses.add(DeferMessage::AfterNavigationReady {
|
||||
messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()],
|
||||
});
|
||||
}
|
||||
PortfolioMessage::CenterLayers { layers } => {
|
||||
if let Some(document) = self.active_document_mut() {
|
||||
let viewport_bounds_quad_pixels = Quad::from_box([DVec2::ZERO, viewport.size().into_dvec2()]); // In viewport pixel coordinates
|
||||
@@ -1157,24 +1001,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
responses.add(NodeGraphMessage::RunDocumentGraph);
|
||||
}
|
||||
}
|
||||
PortfolioMessage::InsertSvg {
|
||||
name,
|
||||
svg,
|
||||
mouse,
|
||||
parent_and_insert_index,
|
||||
} => {
|
||||
if self.document_ids.is_empty() {
|
||||
responses.add(PortfolioMessage::OpenSvg { name, svg });
|
||||
} else {
|
||||
responses.add(DocumentMessage::InsertSvg {
|
||||
name,
|
||||
svg,
|
||||
mouse,
|
||||
parent_and_insert_index,
|
||||
place_at_origin: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
PortfolioMessage::PrevDocument => {
|
||||
if let Some(active_document_id) = self.active_document_id {
|
||||
let len = self.document_ids.len();
|
||||
@@ -1235,12 +1061,8 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
ShortcutLabel::new(action_shortcut!(DialogMessageDiscriminant::RequestNewDocumentDialog)).widget_instance(),
|
||||
],
|
||||
vec![
|
||||
TextButton::new("Open Document")
|
||||
.icon("Folder")
|
||||
.flush(true)
|
||||
.on_commit(|_| PortfolioMessage::Open.into())
|
||||
.widget_instance(),
|
||||
ShortcutLabel::new(action_shortcut!(PortfolioMessageDiscriminant::Open)).widget_instance(),
|
||||
TextButton::new("Open Document").icon("Folder").flush(true).on_commit(|_| IngestMessage::Open.into()).widget_instance(),
|
||||
ShortcutLabel::new(action_shortcut!(IngestMessageDiscriminant::Open)).widget_instance(),
|
||||
],
|
||||
vec![
|
||||
TextButton::new("Open Demo Artwork")
|
||||
@@ -1652,9 +1474,9 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
|
||||
fn actions(&self) -> ActionList {
|
||||
let mut common = actions!(PortfolioMessageDiscriminant;
|
||||
Open,
|
||||
ToggleFocusDocument,
|
||||
);
|
||||
common.extend(actions!(IngestMessageDiscriminant; Open));
|
||||
|
||||
// Extend with actions that require an active document
|
||||
if let Some(document) = self.active_document() {
|
||||
@@ -1666,8 +1488,8 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
ToggleRulers,
|
||||
NextDocument,
|
||||
PrevDocument,
|
||||
Import,
|
||||
));
|
||||
common.extend(actions!(IngestMessageDiscriminant; Import));
|
||||
}
|
||||
|
||||
// Extend with actions that are disabled when focusing the document
|
||||
@@ -1782,22 +1604,6 @@ impl PortfolioMessageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_file(path: &Path, content: Vec<u8>) -> FileContent {
|
||||
let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or_default().to_lowercase();
|
||||
match extension.as_str() {
|
||||
FILE_EXTENSION => match String::from_utf8(content) {
|
||||
Ok(content) => FileContent::Document(content),
|
||||
Err(_) => FileContent::Unsupported,
|
||||
},
|
||||
GDD_FILE_EXTENSION => FileContent::GddDocument(content),
|
||||
"svg" => match String::from_utf8(content) {
|
||||
Ok(content) => FileContent::Svg(content),
|
||||
Err(_) => FileContent::Unsupported,
|
||||
},
|
||||
_ => FileContent::Image(content),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_document(
|
||||
&mut self,
|
||||
mut new_document: DocumentMessageHandler,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
mod resource_upload_message;
|
||||
mod resource_upload_message_handler;
|
||||
|
||||
pub mod utility_types;
|
||||
|
||||
#[doc(inline)]
|
||||
pub use resource_upload_message::{ResourceUploadMessage, ResourceUploadMessageDiscriminant};
|
||||
#[doc(inline)]
|
||||
pub use resource_upload_message_handler::{ResourceUploadMessageContext, ResourceUploadMessageHandler};
|
||||
@@ -1,14 +0,0 @@
|
||||
use super::utility_types::UploadTarget;
|
||||
use crate::messages::prelude::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[impl_message(Message, PortfolioMessage, ResourceUpload)]
|
||||
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ResourceUploadMessage {
|
||||
/// Opens a file dialog filtered to what the target accepts, whose pick arrives as [`ResourceUploadMessage::ReceiveUpload`].
|
||||
RequestUpload { target: UploadTarget },
|
||||
/// The file picked for the pending [`ResourceUploadMessage::RequestUpload`].
|
||||
ReceiveUpload { name: Option<String>, data: Arc<[u8]> },
|
||||
/// Stores a file as an embedded resource of the active document and hands it to its target.
|
||||
Upload { name: Option<String>, data: Arc<[u8]>, target: UploadTarget },
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
use super::utility_types::{ImageResource, UNSUPPORTED_IMAGE_FILE, UploadTarget, decoded_image_size};
|
||||
use crate::messages::prelude::*;
|
||||
use graph_craft::application_io::resource::ResourceId;
|
||||
use graph_craft::document::value::TaggedValue;
|
||||
|
||||
#[derive(ExtractField)]
|
||||
pub struct ResourceUploadMessageContext {
|
||||
/// Whether a document is open to receive an image layer, since otherwise the image opens its own
|
||||
pub document_open: bool,
|
||||
}
|
||||
|
||||
/// Runs the file dialog for resource uploads and stores every upload in the active document before handing it to its target.
|
||||
#[derive(Debug, Default, ExtractField)]
|
||||
pub struct ResourceUploadMessageHandler {
|
||||
/// Where the file from the open dialog goes once it is picked
|
||||
pending_target: Option<UploadTarget>,
|
||||
}
|
||||
|
||||
#[message_handler_data]
|
||||
impl MessageHandler<ResourceUploadMessage, ResourceUploadMessageContext> for ResourceUploadMessageHandler {
|
||||
fn process_message(&mut self, message: ResourceUploadMessage, responses: &mut VecDeque<Message>, context: ResourceUploadMessageContext) {
|
||||
match message {
|
||||
ResourceUploadMessage::RequestUpload { target } => {
|
||||
self.pending_target = Some(target);
|
||||
responses.add(FrontendMessage::TriggerUploadResource { filters: target.filters() });
|
||||
}
|
||||
ResourceUploadMessage::ReceiveUpload { name, data } => {
|
||||
let Some(target) = self.pending_target.take() else {
|
||||
log::warn!("A file was picked without a pending upload request");
|
||||
return;
|
||||
};
|
||||
responses.add(ResourceUploadMessage::Upload { name, data, target });
|
||||
}
|
||||
ResourceUploadMessage::Upload { name, data, target } => {
|
||||
let reject = |responses: &mut VecDeque<Message>, description: &str| {
|
||||
responses.add(DialogMessage::DisplayDialogError {
|
||||
title: "Unsupported image format".into(),
|
||||
description: description.into(),
|
||||
});
|
||||
};
|
||||
|
||||
match target {
|
||||
UploadTarget::NodeInput {
|
||||
document_id,
|
||||
node_id,
|
||||
input_index,
|
||||
kind,
|
||||
} => {
|
||||
if let Some(description) = kind.rejection(&data) {
|
||||
return reject(responses, description);
|
||||
}
|
||||
|
||||
// The file goes to the document that asked for it, which may no longer be active or open once the dialog closes
|
||||
let resource_id = ResourceId::new();
|
||||
let messages = [
|
||||
DocumentMessage::AddTransaction,
|
||||
DocumentMessage::Resource(ResourceMessage::StoreEmbedded { resource_id, data }),
|
||||
DocumentMessage::NodeGraph(NodeGraphMessage::SetInputValue {
|
||||
node_id,
|
||||
input_index,
|
||||
value: Box::new(TaggedValue::Resource(resource_id)),
|
||||
}),
|
||||
];
|
||||
for message in messages {
|
||||
responses.add(PortfolioMessage::DocumentPassMessage { document_id, message });
|
||||
}
|
||||
}
|
||||
UploadTarget::Layer { .. } | UploadTarget::Document => {
|
||||
let Some((width, height)) = decoded_image_size(&data) else {
|
||||
return reject(responses, UNSUPPORTED_IMAGE_FILE);
|
||||
};
|
||||
|
||||
// A layer needs a document to land in, so without one the image opens its own, wrapped in an artboard once rendered
|
||||
let (mouse, parent_and_insert_index, place_at_origin) = match target {
|
||||
UploadTarget::Layer { mouse, parent_and_insert_index } if context.document_open => (mouse, parent_and_insert_index, false),
|
||||
_ => {
|
||||
// An empty name becomes the next available "Untitled Document"
|
||||
responses.add(PortfolioMessage::NewDocumentWithName {
|
||||
name: name.clone().unwrap_or_default(),
|
||||
});
|
||||
(None, None, true)
|
||||
}
|
||||
};
|
||||
|
||||
let resource_id = ResourceId::new();
|
||||
responses.add(ResourceMessage::StoreEmbedded { resource_id, data });
|
||||
responses.add(DocumentMessage::InsertImage {
|
||||
name,
|
||||
image: ImageResource { resource_id, width, height },
|
||||
mouse,
|
||||
parent_and_insert_index,
|
||||
place_at_origin,
|
||||
});
|
||||
|
||||
if place_at_origin {
|
||||
responses.add(DeferMessage::AfterGraphRun {
|
||||
messages: vec![
|
||||
DocumentMessage::WrapContentInArtboard {
|
||||
place_artboard_at_origin: true,
|
||||
artboard_canvas: None,
|
||||
}
|
||||
.into(),
|
||||
],
|
||||
});
|
||||
responses.add(DeferMessage::AfterNavigationReady {
|
||||
messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn actions(&self) -> ActionList {
|
||||
actions!(ResourceUploadMessageDiscriminant;)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::messages::portfolio::resource_upload::utility_types::ResourceFileKind;
|
||||
use graph_craft::document::NodeId;
|
||||
use graphene_std::Color;
|
||||
use graphene_std::raster::Image;
|
||||
|
||||
fn upload(data: &[u8], target: UploadTarget, document_open: bool) -> VecDeque<Message> {
|
||||
let mut responses = VecDeque::new();
|
||||
let message = ResourceUploadMessage::Upload {
|
||||
name: None,
|
||||
data: data.into(),
|
||||
target,
|
||||
};
|
||||
ResourceUploadMessageHandler::default().process_message(message, &mut responses, ResourceUploadMessageContext { document_open });
|
||||
responses
|
||||
}
|
||||
|
||||
fn stores_a_resource(message: &Message) -> bool {
|
||||
matches!(
|
||||
message,
|
||||
Message::Portfolio(PortfolioMessage::Document(DocumentMessage::Resource(ResourceMessage::StoreEmbedded { .. })))
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_an_image_is_rejected_before_it_is_stored() {
|
||||
let target = UploadTarget::NodeInput {
|
||||
document_id: DocumentId(3),
|
||||
node_id: NodeId(7),
|
||||
input_index: 1,
|
||||
kind: ResourceFileKind::RasterImage,
|
||||
};
|
||||
let responses = upload(b"not an image", target, true);
|
||||
|
||||
assert!(!responses.iter().any(stores_a_resource), "a rejected file should not become a resource");
|
||||
assert!(
|
||||
responses.iter().any(|message| matches!(message, Message::Dialog(DialogMessage::DisplayDialogError { .. }))),
|
||||
"the user should be told why"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picked_file_goes_to_the_target_of_the_pending_request() {
|
||||
let target = UploadTarget::NodeInput {
|
||||
document_id: DocumentId(3),
|
||||
node_id: NodeId(7),
|
||||
input_index: 1,
|
||||
kind: ResourceFileKind::Any,
|
||||
};
|
||||
let mut handler = ResourceUploadMessageHandler::default();
|
||||
let context = || ResourceUploadMessageContext { document_open: true };
|
||||
|
||||
let mut responses = VecDeque::new();
|
||||
handler.process_message(ResourceUploadMessage::RequestUpload { target }, &mut responses, context());
|
||||
assert!(responses.contains(&FrontendMessage::TriggerUploadResource { filters: Vec::new() }.into()), "the dialog should open");
|
||||
|
||||
let mut responses = VecDeque::new();
|
||||
let picked = ResourceUploadMessage::ReceiveUpload {
|
||||
name: Some("file.bin".into()),
|
||||
data: b"bytes".as_slice().into(),
|
||||
};
|
||||
handler.process_message(picked, &mut responses, context());
|
||||
let expected = ResourceUploadMessage::Upload {
|
||||
name: Some("file.bin".into()),
|
||||
data: b"bytes".as_slice().into(),
|
||||
target,
|
||||
};
|
||||
assert!(responses.contains(&expected.into()), "the picked file should be uploaded to the requested target");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_layer_opens_a_document_when_none_is_open() {
|
||||
let png = Image::new(1, 1, Color::WHITE).to_png();
|
||||
let target = UploadTarget::Layer {
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
};
|
||||
let opens_a_document = |message: &Message| matches!(message, Message::Portfolio(PortfolioMessage::NewDocumentWithName { .. }));
|
||||
let inserts_at_origin = |message: &Message| matches!(message, Message::Portfolio(PortfolioMessage::Document(DocumentMessage::InsertImage { place_at_origin, .. })) if *place_at_origin);
|
||||
|
||||
let responses = upload(&png, target, true);
|
||||
assert!(responses.iter().any(stores_a_resource), "an image should be stored as a resource");
|
||||
assert!(
|
||||
!responses.iter().any(opens_a_document) && !responses.iter().any(inserts_at_origin),
|
||||
"an open document should receive the layer"
|
||||
);
|
||||
|
||||
let responses = upload(&png, target, false);
|
||||
assert!(
|
||||
responses.iter().position(opens_a_document) < responses.iter().position(stores_a_resource),
|
||||
"the document should exist before the resource is stored in it"
|
||||
);
|
||||
assert!(responses.iter().any(inserts_at_origin), "the image should sit at the origin of its new document");
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
use crate::messages::frontend::utility_types::FileFilter;
|
||||
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use crate::messages::prelude::DocumentId;
|
||||
use graph_craft::application_io::resource::ResourceId;
|
||||
use graph_craft::document::NodeId;
|
||||
|
||||
/// The raster image formats the editor decodes, by file extension.
|
||||
pub const RASTER_IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "bmp", "gif"];
|
||||
pub const VECTOR_IMAGE_EXTENSIONS: &[&str] = &["svg"];
|
||||
|
||||
/// The dialog error shown for a file that does not decode as a raster image.
|
||||
pub const UNSUPPORTED_IMAGE_FILE: &str = "The loaded file is not a supported bitmap image format.";
|
||||
|
||||
/// The file dialog filter for the raster image formats the editor decodes.
|
||||
pub fn raster_image_file_filter() -> FileFilter {
|
||||
FileFilter {
|
||||
name: "Image".into(),
|
||||
extensions: RASTER_IMAGE_EXTENSIONS.iter().map(|extension| extension.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file dialog filter for every image the editor opens or imports, vector as well as raster.
|
||||
pub fn image_file_filter() -> FileFilter {
|
||||
FileFilter {
|
||||
name: "Image".into(),
|
||||
extensions: RASTER_IMAGE_EXTENSIONS.iter().chain(VECTOR_IMAGE_EXTENSIONS.iter()).map(|ext| ext.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixel size of a file that fully decodes as a raster image.
|
||||
pub fn decoded_image_size(data: &[u8]) -> Option<(u32, u32)> {
|
||||
image::load_from_memory(data).ok().map(|image| (image.width(), image.height()))
|
||||
}
|
||||
|
||||
/// What a file must decode as before a node input accepts it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ResourceFileKind {
|
||||
#[default]
|
||||
Any,
|
||||
RasterImage,
|
||||
}
|
||||
|
||||
impl ResourceFileKind {
|
||||
/// The file dialog filters offered for this kind.
|
||||
pub fn filters(self) -> Vec<FileFilter> {
|
||||
match self {
|
||||
Self::Any => Vec::new(),
|
||||
Self::RasterImage => vec![raster_image_file_filter()],
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog error to show when the file's contents do not decode as this kind.
|
||||
pub fn rejection(self, data: &[u8]) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Any => None,
|
||||
Self::RasterImage => decoded_image_size(data).is_none().then_some(UNSUPPORTED_IMAGE_FILE),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an uploaded file goes once it is stored as a resource.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum UploadTarget {
|
||||
/// A node input in the given document that accepts the given kind of file.
|
||||
NodeInput {
|
||||
document_id: DocumentId,
|
||||
node_id: NodeId,
|
||||
input_index: usize,
|
||||
kind: ResourceFileKind,
|
||||
},
|
||||
/// A new image layer in the active document, centered on the mouse or else the viewport.
|
||||
Layer {
|
||||
mouse: Option<(f64, f64)>,
|
||||
parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>,
|
||||
},
|
||||
/// A new document sized to the image.
|
||||
Document,
|
||||
}
|
||||
|
||||
impl UploadTarget {
|
||||
/// The file dialog filters for what this target accepts.
|
||||
pub fn filters(self) -> Vec<FileFilter> {
|
||||
match self {
|
||||
Self::NodeInput { kind, .. } => kind.filters(),
|
||||
Self::Layer { .. } | Self::Document => vec![raster_image_file_filter()],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An uploaded image by its stored resource and pixel size.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ImageResource {
|
||||
pub resource_id: ResourceId,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
@@ -784,16 +784,3 @@ impl PanelLayoutSubdivision {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum FileContent {
|
||||
/// A legacy `.graphite` document (serialized runtime JSON).
|
||||
Document(String),
|
||||
/// A `.gdd` document container (archive bytes).
|
||||
GddDocument(Vec<u8>),
|
||||
/// Any other file, expected to be a bitmap image.
|
||||
Image(Vec<u8>),
|
||||
/// An SVG file string.
|
||||
Svg(String),
|
||||
/// Any other unsupported/unrecognized file type.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ pub use crate::messages::portfolio::document::properties_panel::{PropertiesPanel
|
||||
pub use crate::messages::portfolio::document::resource::{ResourceMessage, ResourceMessageContext, ResourceMessageDiscriminant, ResourceMessageHandler};
|
||||
pub use crate::messages::portfolio::document::{DocumentMessage, DocumentMessageContext, DocumentMessageDiscriminant, DocumentMessageHandler};
|
||||
pub use crate::messages::portfolio::fonts::{FontsMessage, FontsMessageContext, FontsMessageDiscriminant, FontsMessageHandler};
|
||||
pub use crate::messages::portfolio::ingest::{IngestMessage, IngestMessageContext, IngestMessageDiscriminant, IngestMessageHandler};
|
||||
pub use crate::messages::portfolio::persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageDiscriminant, PersistentStateMessageHandler};
|
||||
pub use crate::messages::portfolio::resource_upload::{ResourceUploadMessage, ResourceUploadMessageContext, ResourceUploadMessageDiscriminant, ResourceUploadMessageHandler};
|
||||
pub use crate::messages::portfolio::{PortfolioMessage, PortfolioMessageContext, PortfolioMessageDiscriminant, PortfolioMessageHandler};
|
||||
pub use crate::messages::preferences::{PreferencesMessage, PreferencesMessageDiscriminant, PreferencesMessageHandler};
|
||||
pub use crate::messages::resource_storage::{ResourceStorageMessage, ResourceStorageMessageContext, ResourceStorageMessageDiscriminant, ResourceStorageMessageHandler};
|
||||
|
||||
@@ -5,7 +5,6 @@ use crate::messages::portfolio::document::utility_types::network_interface::{Flo
|
||||
use crate::messages::prelude::*;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graph_craft::ProtoNodeIdentifier;
|
||||
use graph_craft::application_io::resource::ResourceId;
|
||||
use graph_craft::document::value::TaggedValue;
|
||||
use graph_craft::document::{DocumentNode, NodeId, NodeInput};
|
||||
use graphene_std::Color;
|
||||
@@ -16,6 +15,7 @@ use graphene_std::vector::style::{FillChoice, PaintOrder, StrokeAlign, StrokeCap
|
||||
use graphene_std::vector::{Gradient, GradientForm, GradientRamp, GradientSettings, PointId, SegmentId, VectorModificationType};
|
||||
use graphene_std::{NodeParameter, ParameterRef};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Returns the ID of the first Spline node in the horizontal flow which is not followed by a `Path` node, or `None` if none exists.
|
||||
pub fn find_spline(document: &DocumentMessageHandler, layer: LayerNodeIdentifier) -> Option<NodeId> {
|
||||
@@ -206,15 +206,10 @@ pub fn merge_points(document: &DocumentMessageHandler, layer: LayerNodeIdentifie
|
||||
responses.add(GraphOperationMessage::Vector { layer, modification_type });
|
||||
}
|
||||
|
||||
/// Create a new bitmap layer showing a stored image resource.
|
||||
pub fn new_image_layer(resource_id: ResourceId, id: NodeId, parent: LayerNodeIdentifier, responses: &mut VecDeque<Message>) -> LayerNodeIdentifier {
|
||||
/// Create a new bitmap layer.
|
||||
pub fn new_image_layer(data: Arc<[u8]>, id: NodeId, parent: LayerNodeIdentifier, responses: &mut VecDeque<Message>) -> LayerNodeIdentifier {
|
||||
let insert_index = 0;
|
||||
responses.add(GraphOperationMessage::NewBitmapLayer {
|
||||
id,
|
||||
resource_id,
|
||||
parent,
|
||||
insert_index,
|
||||
});
|
||||
responses.add(GraphOperationMessage::NewBitmapLayer { id, data, parent, insert_index });
|
||||
LayerNodeIdentifier::new_unchecked(id)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::application::Editor;
|
||||
use crate::messages::input_mapper::utility_types::keyboard::ModifierKeys;
|
||||
use crate::messages::input_mapper::utility_types::pointer::{EditorPointerState, MouseKeys, ViewportPosition};
|
||||
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
|
||||
use crate::messages::portfolio::resource_upload::utility_types::UploadTarget;
|
||||
use crate::messages::portfolio::ingest::utility_types::IngestAction;
|
||||
use crate::messages::prelude::*;
|
||||
use crate::messages::tool::tool_messages::tool_prelude::Key;
|
||||
use crate::messages::tool::utility_types::ToolType;
|
||||
@@ -243,10 +243,11 @@ impl EditorTestUtils {
|
||||
}
|
||||
|
||||
pub async fn create_raster_image(&mut self, image: graphene_std::raster::Image<Color>, mouse: Option<(f64, f64)>) {
|
||||
self.handle_message(ResourceUploadMessage::Upload {
|
||||
name: None,
|
||||
data: image.to_png().into(),
|
||||
target: UploadTarget::Layer { mouse, parent_and_insert_index: None },
|
||||
self.handle_message(IngestMessage::Ingest {
|
||||
data: image.to_png(),
|
||||
action: mouse.map_or(IngestAction::Paste, |mouse| IngestAction::DropOnCanvas { mouse }),
|
||||
mime_type: String::new(),
|
||||
path: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user