mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-19 19:08:05 +08:00
Add a dropdown for resource selection and adopt it in the 'Image' node (#4543)
* Add a dropdown for resource selection and adopt it in the Image node * Route every image upload through a new ResourceUpload handler with desktop support and migrate wired 'Image' node inputs * Show each resource's user count in the resource picker dropdown * Send a picked resource file to the document that requested it, list every non-font resource in the picker, and link sibling message docs
This commit is contained in:
@@ -22,6 +22,11 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess
|
||||
OpenFileDialogContext::Import => {
|
||||
dispatcher.queue_desktop_wrapper_message(DesktopWrapperMessage::ImportFile { path, content });
|
||||
}
|
||||
OpenFileDialogContext::UploadResource => {
|
||||
let name = path.file_name().map(|name| name.to_string_lossy().to_string());
|
||||
let message = ResourceUploadMessage::ReceiveUpload { name, data: content.into() };
|
||||
dispatcher.queue_editor_message(message);
|
||||
}
|
||||
},
|
||||
DesktopWrapperMessage::SaveFileDialogResult { path, context } => match context {
|
||||
SaveFileDialogContext::Document { document_id, content } => {
|
||||
|
||||
@@ -26,6 +26,14 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
|
||||
context: OpenFileDialogContext::Import,
|
||||
});
|
||||
}
|
||||
FrontendMessage::TriggerUploadResource { filters } => {
|
||||
dispatcher.respond(DesktopFrontendMessage::OpenFileDialog {
|
||||
title: "Select File".to_string(),
|
||||
filters,
|
||||
multiple: false,
|
||||
context: OpenFileDialogContext::UploadResource,
|
||||
});
|
||||
}
|
||||
FrontendMessage::TriggerSaveDocument {
|
||||
document_id,
|
||||
name,
|
||||
|
||||
@@ -107,6 +107,7 @@ pub enum DesktopWrapperMessage {
|
||||
pub enum OpenFileDialogContext {
|
||||
Open,
|
||||
Import,
|
||||
UploadResource,
|
||||
}
|
||||
|
||||
pub enum SaveFileDialogContext {
|
||||
|
||||
@@ -5,6 +5,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;
|
||||
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;
|
||||
@@ -60,11 +61,13 @@ impl MessageHandler<ClipboardMessage, ClipboardMessageContext<'_>> for Clipboard
|
||||
});
|
||||
}
|
||||
ClipboardContentRaw::Image { data, width, height } => {
|
||||
responses.add(PortfolioMessage::InsertImage {
|
||||
image: Image::from_image_data(&data, width, height),
|
||||
responses.add(ResourceUploadMessage::Upload {
|
||||
name: None,
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
data: Image::from_image_data(&data, width, height).to_png().into(),
|
||||
target: UploadTarget::Layer {
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -93,6 +93,9 @@ pub enum FrontendMessage {
|
||||
TriggerImport {
|
||||
filters: Vec<FileFilter>,
|
||||
},
|
||||
TriggerUploadResource {
|
||||
filters: Vec<FileFilter>,
|
||||
},
|
||||
TriggerSaveDocument {
|
||||
document_id: DocumentId,
|
||||
name: String,
|
||||
|
||||
@@ -7,14 +7,13 @@ 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 graph_craft::document::NodeId;
|
||||
use graphene_std::Appearance;
|
||||
use graphene_std::Color;
|
||||
use graphene_std::raster::BlendMode;
|
||||
use graphene_std::raster::Image;
|
||||
use graphene_std::transform::Footprint;
|
||||
use graphene_std::vector::Vector;
|
||||
use graphene_std::vector::click_target::ClickTarget;
|
||||
@@ -117,7 +116,7 @@ pub enum DocumentMessage {
|
||||
},
|
||||
InsertImage {
|
||||
name: Option<String>,
|
||||
image: Image<Color>,
|
||||
image: ImageResource,
|
||||
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`
|
||||
|
||||
@@ -249,6 +249,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
DocumentMessage::PropertiesPanel(message) => {
|
||||
let context = PropertiesPanelMessageContext {
|
||||
executor,
|
||||
document_id,
|
||||
network_interface: &mut self.network_interface,
|
||||
resources: &self.resources,
|
||||
selection_network_path: &self.selection_network_path,
|
||||
@@ -826,7 +827,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
|
||||
responses.add(DocumentMessage::StartTransaction);
|
||||
|
||||
let layer = graph_modification_utils::new_image_layer(image, layer_node_id, layer_parent, responses);
|
||||
let layer = graph_modification_utils::new_image_layer(image.resource_id, layer_node_id, layer_parent, responses);
|
||||
|
||||
if let Some(name) = name {
|
||||
responses.add(NodeGraphMessage::SetDisplayName {
|
||||
|
||||
@@ -3,10 +3,10 @@ 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::raster_types::Image;
|
||||
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};
|
||||
@@ -141,7 +141,7 @@ pub enum GraphOperationMessage {
|
||||
},
|
||||
NewBitmapLayer {
|
||||
id: NodeId,
|
||||
image: Image<Color>,
|
||||
resource_id: ResourceId,
|
||||
parent: LayerNodeIdentifier,
|
||||
insert_index: usize,
|
||||
},
|
||||
|
||||
@@ -267,10 +267,15 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
|
||||
responses.add_front(NodeGraphMessage::SelectedNodesSet { nodes: vec![id] });
|
||||
responses.add(NodeGraphMessage::RunDocumentGraph);
|
||||
}
|
||||
GraphOperationMessage::NewBitmapLayer { id, image, parent, insert_index } => {
|
||||
GraphOperationMessage::NewBitmapLayer {
|
||||
id,
|
||||
resource_id,
|
||||
parent,
|
||||
insert_index,
|
||||
} => {
|
||||
let mut modify_inputs = ModifyInputsContext::new(network_interface, responses);
|
||||
let layer = modify_inputs.create_layer(id);
|
||||
modify_inputs.insert_image_data(image, layer);
|
||||
modify_inputs.insert_image_data(resource_id, layer);
|
||||
network_interface.move_layer_to_stack(layer, parent, insert_index, &[]);
|
||||
responses.add(NodeGraphMessage::RunDocumentGraph);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ use graph_craft::document::value::TaggedValue;
|
||||
use graph_craft::document::{NodeId, NodeInput};
|
||||
use graph_craft::{ProtoNodeIdentifier, list};
|
||||
use graphene_std::raster::BlendMode;
|
||||
use graphene_std::raster_types::Image;
|
||||
use graphene_std::text::{Font, TypesettingConfig};
|
||||
use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, PaintOrder, Stroke};
|
||||
use graphene_std::vector::{Gradient, GradientRamp, Vector, VectorModification, VectorModificationType};
|
||||
@@ -270,20 +269,14 @@ impl<'a> ModifyInputsContext<'a> {
|
||||
self.network_interface.set_chain_position(node_id, &[]);
|
||||
}
|
||||
|
||||
pub fn insert_image_data(&mut self, image: Image<Color>, layer: LayerNodeIdentifier) {
|
||||
pub fn insert_image_data(&mut self, resource_id: ResourceId, 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: image.to_png().into(),
|
||||
});
|
||||
|
||||
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([Some(NodeInput::value(TaggedValue::Resource(resource_id), false))]);
|
||||
.node_template_input_override([None, Some(NodeInput::value(TaggedValue::Resource(resource_id), false))]);
|
||||
|
||||
let image_node_id = NodeId::new();
|
||||
self.network_interface.insert_node(image_node_id, image_node, &[]);
|
||||
|
||||
@@ -7,7 +7,8 @@ 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::prelude::{FontsMessage, FontsMessageHandler, Message, ResourceMessageHandler, Responses};
|
||||
use crate::messages::portfolio::resource_upload::utility_types::ResourceFileKind;
|
||||
use crate::messages::prelude::{DocumentId, FontsMessage, FontsMessageHandler, Message, ResourceMessageHandler, Responses};
|
||||
use crate::node_graph_executor::NodeGraphExecutor;
|
||||
use glam::DVec2;
|
||||
use graph_craft::ProtoNodeIdentifier;
|
||||
@@ -26,6 +27,7 @@ use std::collections::{HashMap, VecDeque};
|
||||
pub struct NodePropertiesContext<'a> {
|
||||
pub responses: &'a mut VecDeque<Message>,
|
||||
pub executor: &'a mut NodeGraphExecutor,
|
||||
pub document_id: DocumentId,
|
||||
pub network_interface: &'a mut NodeNetworkInterface,
|
||||
pub resources: &'a ResourceMessageHandler,
|
||||
pub fonts: &'a FontsMessageHandler,
|
||||
@@ -1461,6 +1463,13 @@ fn static_input_properties() -> InputProperties {
|
||||
Ok(result)
|
||||
}),
|
||||
);
|
||||
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);
|
||||
Ok(vec![LayoutGroup::row(widgets)])
|
||||
}),
|
||||
);
|
||||
map.insert(
|
||||
"artboard_background".to_string(),
|
||||
Box::new(|node_id, index, context| {
|
||||
|
||||
@@ -7,15 +7,16 @@ 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::prelude::*;
|
||||
use crate::messages::tool::common_functionality::graph_modification_utils;
|
||||
use choice::enum_choice;
|
||||
use dyn_any::DynAny;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graph_craft::application_io::resource::ResourceId;
|
||||
use graph_craft::application_io::resource::{DataSource, Resource, ResourceId};
|
||||
use graph_craft::document::value::TaggedValue;
|
||||
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput};
|
||||
use graph_craft::{Type, concrete};
|
||||
use graph_craft::{Type, concrete, item};
|
||||
use graphene_std::animation::RealTimeMode;
|
||||
use graphene_std::color::SRGBA8;
|
||||
use graphene_std::extract_xy::XY;
|
||||
@@ -341,6 +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(),
|
||||
// ===============================
|
||||
// MANUALLY IMPLEMENTED ENUM TYPES
|
||||
// ===============================
|
||||
@@ -1288,6 +1290,107 @@ 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> {
|
||||
let mut widgets = start_widgets(¶meter_widgets_info);
|
||||
|
||||
let Some(input) = parameter_widgets_info.input() else {
|
||||
log::warn!("A widget failed to be built because its node's input index is invalid.");
|
||||
return vec![];
|
||||
};
|
||||
let selected = match input.as_non_exposed_value() {
|
||||
Some(TaggedValue::Resource(resource_id)) => Some(*resource_id),
|
||||
Some(TaggedValue::TypeDefault(_)) => None,
|
||||
_ => 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,
|
||||
index,
|
||||
resources,
|
||||
network_interface,
|
||||
..
|
||||
} = parameter_widgets_info;
|
||||
let user_counts = network_interface.resource_user_counts();
|
||||
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 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" }),
|
||||
};
|
||||
let uses = match users {
|
||||
0 => "unused".to_string(),
|
||||
1 => "1 use".to_string(),
|
||||
users => format!("{users} uses"),
|
||||
};
|
||||
(info.id, format!("{hash} · {uses}"), tooltip_description)
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
// Entries assign only on click, since a hover preview leaves the replaced file unreferenced and garbage collected
|
||||
let assign_on_click = |value: TaggedValue| {
|
||||
move |_: &()| Message::Batched {
|
||||
messages: Box::new([
|
||||
DocumentMessage::AddTransaction.into(),
|
||||
NodeGraphMessage::SetInputValue {
|
||||
node_id,
|
||||
input_index: index,
|
||||
value: value.clone().into(),
|
||||
}
|
||||
.into(),
|
||||
]),
|
||||
}
|
||||
};
|
||||
let none = MenuListEntry::new("none")
|
||||
.label("None")
|
||||
.tooltip_description("No resource assigned to this input.")
|
||||
.on_update(|_| Message::NoOp)
|
||||
.on_commit(assign_on_click(TaggedValue::TypeDefault(item!(Resource))));
|
||||
let browse = MenuListEntry::new("browse")
|
||||
.label("Browse…")
|
||||
.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,
|
||||
},
|
||||
}
|
||||
.into()
|
||||
});
|
||||
let file_entries = files
|
||||
.iter()
|
||||
.map(|(resource_id, label, tooltip_description)| {
|
||||
MenuListEntry::new(format!("{resource_id:?}"))
|
||||
.label(label.clone())
|
||||
.tooltip_description(tooltip_description.clone())
|
||||
.on_update(|_| Message::NoOp)
|
||||
.on_commit(assign_on_click(TaggedValue::Resource(*resource_id)))
|
||||
})
|
||||
.collect();
|
||||
let selected_index = match selected {
|
||||
None => Some(0),
|
||||
Some(selected) => files.iter().position(|(resource_id, ..)| *resource_id == selected).map(|position| position as u32 + 2),
|
||||
};
|
||||
|
||||
widgets.extend_from_slice(&[
|
||||
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
|
||||
DropdownInput::new(vec![vec![none, browse], file_entries]).selected_index(selected_index).widget_instance(),
|
||||
]);
|
||||
widgets
|
||||
}
|
||||
|
||||
pub fn get_document_node<'a>(node_id: NodeId, context: &'a NodePropertiesContext<'a>) -> Result<&'a DocumentNode, String> {
|
||||
let network = context
|
||||
.network_interface
|
||||
@@ -3343,6 +3446,7 @@ pub fn math_properties(node_id: NodeId, context: &mut NodePropertiesContext) ->
|
||||
}
|
||||
|
||||
pub struct ParameterWidgetsInfo<'a> {
|
||||
document_id: DocumentId,
|
||||
network_interface: &'a NodeNetworkInterface,
|
||||
resources: &'a ResourceMessageHandler,
|
||||
selection_network_path: &'a [NodeId],
|
||||
@@ -3388,6 +3492,7 @@ impl<'a> ParameterWidgetsInfo<'a> {
|
||||
let document_node = context.network_interface.document_node(&node_id, context.selection_network_path);
|
||||
|
||||
ParameterWidgetsInfo {
|
||||
document_id: context.document_id,
|
||||
network_interface: context.network_interface,
|
||||
resources: context.resources,
|
||||
selection_network_path: context.selection_network_path,
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::node_graph_executor::NodeGraphExecutor;
|
||||
#[derive(ExtractField)]
|
||||
pub struct PropertiesPanelMessageContext<'a> {
|
||||
pub executor: &'a mut NodeGraphExecutor,
|
||||
pub document_id: DocumentId,
|
||||
pub network_interface: &'a mut NodeNetworkInterface,
|
||||
pub resources: &'a ResourceMessageHandler,
|
||||
pub selection_network_path: &'a [NodeId],
|
||||
@@ -26,6 +27,7 @@ impl MessageHandler<PropertiesPanelMessage, PropertiesPanelMessageContext<'_>> f
|
||||
fn process_message(&mut self, message: PropertiesPanelMessage, responses: &mut VecDeque<Message>, context: PropertiesPanelMessageContext) {
|
||||
let PropertiesPanelMessageContext {
|
||||
executor,
|
||||
document_id,
|
||||
network_interface,
|
||||
resources,
|
||||
selection_network_path,
|
||||
@@ -51,6 +53,7 @@ impl MessageHandler<PropertiesPanelMessage, PropertiesPanelMessageContext<'_>> f
|
||||
let mut node_properties_context = NodePropertiesContext {
|
||||
responses,
|
||||
executor,
|
||||
document_id,
|
||||
network_interface,
|
||||
resources,
|
||||
selection_network_path,
|
||||
|
||||
@@ -15,6 +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::test_utils::test_prelude::*;
|
||||
use graphene_std::NodeParameter;
|
||||
use graphene_std::vector::style::RenderMode;
|
||||
@@ -494,6 +495,18 @@ async fn live_undo_new_document_draw_rect() {
|
||||
/// 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();
|
||||
@@ -503,15 +516,7 @@ async fn paste_image_with_name_is_one_undo_step() {
|
||||
|
||||
// Paste with a name so the handler emits the `SetDisplayName` sub-step that historically opened its
|
||||
// own transaction. `create_raster_image` passes `name: None` and so wouldn't exercise this path.
|
||||
let image = Image::new(2, 2, Color::WHITE);
|
||||
editor
|
||||
.handle_message(PortfolioMessage::InsertImage {
|
||||
name: Some("pasted".into()),
|
||||
image,
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
})
|
||||
.await;
|
||||
editor.handle_message(paste_named_image()).await;
|
||||
assert_eq!(
|
||||
editor.active_document().metadata().all_layers().count(),
|
||||
layers_before + 1,
|
||||
@@ -526,6 +531,37 @@ 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::document::DocumentNodeImplementation;
|
||||
use graph_craft::document::value::TaggedValue;
|
||||
use graph_craft::item;
|
||||
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
editor.create_raster_image(Image::new(2, 2, Color::WHITE), None).await;
|
||||
|
||||
let image_node_id = editor
|
||||
.active_document()
|
||||
.network_interface
|
||||
.document_network()
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|(_, node)| matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if *identifier == graphene_std::raster_nodes::std_nodes::image::IDENTIFIER))
|
||||
.map(|(id, _)| *id)
|
||||
.expect("the pasted image should be an Image node");
|
||||
editor
|
||||
.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(),
|
||||
})
|
||||
.await;
|
||||
|
||||
editor.eval_graph().await.expect("an Image node with no file chosen should still evaluate");
|
||||
}
|
||||
|
||||
/// Undoing an image paste reverts the interaction's `AddResource` in the `Gdd` cursor while the runtime keeps
|
||||
/// the resource alive for legacy redo, so the cursor legitimately holds fewer resources than a fresh
|
||||
/// `from_runtime`. Drives the relaxed comparison: every resource the current network references must be in
|
||||
@@ -538,15 +574,7 @@ async fn undo_image_paste_resources_subset_of_runtime() {
|
||||
let byte_store = mount_in_memory_storage(&mut editor).await;
|
||||
editor.active_document_mut().commit_storage_snapshot(&byte_store, true);
|
||||
|
||||
let image = Image::new(2, 2, Color::WHITE);
|
||||
editor
|
||||
.handle_message(PortfolioMessage::InsertImage {
|
||||
name: Some("pasted".into()),
|
||||
image,
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
})
|
||||
.await;
|
||||
editor.handle_message(paste_named_image()).await;
|
||||
|
||||
editor.handle_message(DocumentMessage::Undo).await;
|
||||
|
||||
@@ -582,15 +610,7 @@ async fn undo_twice_steps_cursor_two_interactions() {
|
||||
editor.draw_rect(0., 0., 100., 100.).await;
|
||||
let after_rect = editor.active_document().network_interface.document_network().clone();
|
||||
|
||||
let image = Image::new(2, 2, Color::WHITE);
|
||||
editor
|
||||
.handle_message(PortfolioMessage::InsertImage {
|
||||
name: Some("pasted".into()),
|
||||
image,
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
})
|
||||
.await;
|
||||
editor.handle_message(paste_named_image()).await;
|
||||
|
||||
// First undo: removes the paste, back to the post-rectangle network. Cursor and legacy must agree.
|
||||
editor.handle_message(DocumentMessage::Undo).await;
|
||||
|
||||
@@ -314,6 +314,13 @@ impl NodeNetworkInterface {
|
||||
collect_network_resources(self.document_network(), target);
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let mut counts = HashMap::new();
|
||||
visit_network_resources(self.document_network(), &mut |id| *counts.entry(id).or_insert(0) += 1);
|
||||
counts
|
||||
}
|
||||
|
||||
pub fn frontend_imports(&self, network_path: &[NodeId]) -> Vec<Option<FrontendGraphOutput>> {
|
||||
match network_path.split_last() {
|
||||
Some((node_id, encapsulating_network_path)) => {
|
||||
|
||||
@@ -866,30 +866,49 @@ pub(crate) enum SoleDependentStep {
|
||||
}
|
||||
|
||||
pub(crate) fn collect_network_resources(network: &NodeNetwork, out: &mut HashSet<ResourceId>) {
|
||||
for export in &network.exports {
|
||||
collect_input_resource(export, out);
|
||||
}
|
||||
for node in network.nodes.values() {
|
||||
collect_node_resources(node, out);
|
||||
}
|
||||
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>) {
|
||||
for input in &node.inputs {
|
||||
collect_input_resource(input, out);
|
||||
}
|
||||
if let DocumentNodeImplementation::Network(nested) = &node.implementation {
|
||||
collect_network_resources(nested, out);
|
||||
}
|
||||
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);
|
||||
}
|
||||
for node in network.nodes.values() {
|
||||
visit_node_resources(node, visit);
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_node_resources(node: &DocumentNode, visit: &mut impl FnMut(ResourceId)) {
|
||||
for input in &node.inputs {
|
||||
visit_input_resource(input, visit);
|
||||
}
|
||||
if let DocumentNodeImplementation::Network(nested) = &node.implementation {
|
||||
visit_network_resources(nested, visit);
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_input_resource(input: &NodeInput, visit: &mut impl FnMut(ResourceId)) {
|
||||
if let NodeInput::Value { tagged_value, .. } = input
|
||||
&& let TaggedValue::Resource(id) = &**tagged_value
|
||||
{
|
||||
out.insert(*id);
|
||||
visit(*id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,4 +930,31 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2140,10 +2140,21 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
|
||||
let _ = document.network_interface.replace_inputs(node_id, network_path, &mut node_template);
|
||||
document
|
||||
.network_interface
|
||||
.set_input(&InputConnector::node_at_index(*node_id, 0), NodeInput::value(TaggedValue::Resource(resource_id), false), network_path);
|
||||
.set_input(&InputConnector::node_at_index(*node_id, 1), NodeInput::value(TaggedValue::Resource(resource_id), false), network_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Move the Image node's resource, whether a stored value or a wire, from the primary input to the first secondary input
|
||||
if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER)
|
||||
&& inputs_count == 1
|
||||
&& !matches!(node.inputs.first().and_then(|input| input.as_value()), Some(TaggedValue::ImageData(_)))
|
||||
{
|
||||
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
|
||||
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
|
||||
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?;
|
||||
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[0].clone(), network_path);
|
||||
}
|
||||
|
||||
// Convert text nodes from the old `editor-api` scope + `Font` input to a single font `Resource` input.
|
||||
// The chosen typeface is recorded as a `DataSource::Font` in the document's resource registry and loaded on open.
|
||||
if reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_std::text::TextNode")) && inputs_count == 13 && matches!(node.inputs.first(), Some(NodeInput::Scope(_))) {
|
||||
@@ -3111,6 +3122,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_legacy_image_shape_stores_its_resource_as_secondary_input() {
|
||||
use graphene_std::raster::Image;
|
||||
|
||||
let resource_id = ResourceId::new();
|
||||
let upstream_id = NodeId(2);
|
||||
let legacy_inputs = [
|
||||
NodeInput::value(TaggedValue::ImageData(Image::new(2, 2, Color::WHITE)), false),
|
||||
NodeInput::value(TaggedValue::Resource(resource_id), false),
|
||||
NodeInput::node(upstream_id, 0),
|
||||
];
|
||||
|
||||
for legacy_input in legacy_inputs {
|
||||
let image_id = NodeId(1);
|
||||
let mut document = DocumentMessageHandler::default();
|
||||
let image_template = |inputs| NodeTemplate {
|
||||
implementation: NodeTemplateImplementation::ProtoNode(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER),
|
||||
inputs,
|
||||
..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.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 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_duplicate_node_replacements() {
|
||||
let mut hashmap = HashMap::<ProtoNodeIdentifier, u32>::new();
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod document_migration;
|
||||
pub mod document_storage_io;
|
||||
pub mod fonts;
|
||||
pub mod persistent_state;
|
||||
pub mod resource_upload;
|
||||
pub mod utility_types;
|
||||
|
||||
#[doc(inline)]
|
||||
@@ -16,3 +17,5 @@ pub use persistent_state::{PersistentStateMessage, PersistentStateMessageContext
|
||||
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,10 +1,9 @@
|
||||
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::*;
|
||||
use graphene_std::Color;
|
||||
use graphene_std::raster::Image;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[impl_message(Message, Portfolio)]
|
||||
@@ -18,6 +17,8 @@ pub enum PortfolioMessage {
|
||||
Fonts(FontsMessage),
|
||||
#[child]
|
||||
PersistentState(PersistentStateMessage),
|
||||
#[child]
|
||||
ResourceUpload(ResourceUploadMessage),
|
||||
|
||||
// Messages
|
||||
Init,
|
||||
@@ -138,20 +139,10 @@ pub enum PortfolioMessage {
|
||||
document_is_saved: bool,
|
||||
document_serialized_content: String,
|
||||
},
|
||||
OpenImage {
|
||||
name: Option<String>,
|
||||
image: Image<Color>,
|
||||
},
|
||||
OpenSvg {
|
||||
name: Option<String>,
|
||||
svg: String,
|
||||
},
|
||||
InsertImage {
|
||||
name: Option<String>,
|
||||
image: Image<Color>,
|
||||
mouse: Option<(f64, f64)>,
|
||||
parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>,
|
||||
},
|
||||
InsertSvg {
|
||||
name: Option<String>,
|
||||
svg: String,
|
||||
|
||||
@@ -15,6 +15,7 @@ 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::*;
|
||||
@@ -24,10 +25,8 @@ use crate::node_graph_executor::{ExportConfig, NodeGraphExecutor};
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graph_craft::application_io::resource::{DataSource, ResourceHash};
|
||||
use graph_craft::document::NodeId;
|
||||
use graphene_std::Color;
|
||||
use graphene_std::raster_types::Image;
|
||||
use graphene_std::renderer::Quad;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::vec;
|
||||
|
||||
@@ -61,6 +60,7 @@ pub struct PortfolioMessageHandler {
|
||||
pub(crate) active_document_id: Option<DocumentId>,
|
||||
persistent_state: PersistentStateMessageHandler,
|
||||
pub fonts: FontsMessageHandler,
|
||||
resource_upload: ResourceUploadMessageHandler,
|
||||
pub executor: NodeGraphExecutor,
|
||||
pub selection_mode: SelectionMode,
|
||||
pub reset_node_definitions_on_open: bool,
|
||||
@@ -116,6 +116,12 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
let context = FontsMessageContext { resource_storage };
|
||||
self.fonts.process_message(message, responses, context);
|
||||
}
|
||||
PortfolioMessage::ResourceUpload(message) => {
|
||||
let context = ResourceUploadMessageContext {
|
||||
document_open: self.active_document().is_some(),
|
||||
};
|
||||
self.resource_upload.process_message(message, responses, context);
|
||||
}
|
||||
|
||||
// Messages
|
||||
PortfolioMessage::Init => {
|
||||
@@ -666,22 +672,14 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
name: "Graphite Document".into(),
|
||||
extensions: vec![FILE_EXTENSION.into(), GDD_FILE_EXTENSION.into()],
|
||||
},
|
||||
FileFilter {
|
||||
name: "Image".into(),
|
||||
extensions: vec!["svg".into(), "png".into(), "jpg".into(), "jpeg".into(), "bmp".into(), "gif".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![FileFilter {
|
||||
name: "Image".into(),
|
||||
extensions: vec!["svg".into(), "png".into(), "jpg".into(), "jpeg".into(), "bmp".into(), "gif".into()],
|
||||
}],
|
||||
});
|
||||
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());
|
||||
@@ -705,8 +703,12 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
FileContent::Svg(svg) => {
|
||||
responses.add(PortfolioMessage::OpenSvg { name, svg });
|
||||
}
|
||||
FileContent::Image(image) => {
|
||||
responses.add(PortfolioMessage::OpenImage { name, image });
|
||||
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
|
||||
@@ -744,12 +746,14 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
parent_and_insert_index: None,
|
||||
});
|
||||
}
|
||||
FileContent::Image(image) => {
|
||||
responses.add(PortfolioMessage::InsertImage {
|
||||
FileContent::Image(data) => {
|
||||
responses.add(ResourceUploadMessage::Upload {
|
||||
name,
|
||||
image,
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
data: data.into(),
|
||||
target: UploadTarget::Layer {
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
FileContent::Unsupported => {
|
||||
@@ -993,34 +997,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
self.tick_autosave_load_progress(responses, false);
|
||||
}
|
||||
}
|
||||
PortfolioMessage::OpenImage { name, image } => {
|
||||
// `NewDocumentWithName`'s handler routes empty/None-equivalent names through `resolve_document_name` which assigns the next available "Untitled Document {N}".
|
||||
responses.add(PortfolioMessage::NewDocumentWithName {
|
||||
name: name.clone().unwrap_or_default(),
|
||||
});
|
||||
|
||||
responses.add(DocumentMessage::InsertImage {
|
||||
name,
|
||||
image,
|
||||
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 image
|
||||
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()],
|
||||
});
|
||||
}
|
||||
PortfolioMessage::OpenSvg { name, svg } => {
|
||||
responses.add(PortfolioMessage::NewDocumentWithName {
|
||||
name: name.clone().unwrap_or_default(),
|
||||
@@ -1181,24 +1157,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
responses.add(NodeGraphMessage::RunDocumentGraph);
|
||||
}
|
||||
}
|
||||
PortfolioMessage::InsertImage {
|
||||
name,
|
||||
image,
|
||||
mouse,
|
||||
parent_and_insert_index,
|
||||
} => {
|
||||
if self.document_ids.is_empty() {
|
||||
responses.add(PortfolioMessage::OpenImage { name, image });
|
||||
} else {
|
||||
responses.add(DocumentMessage::InsertImage {
|
||||
name,
|
||||
image,
|
||||
mouse,
|
||||
parent_and_insert_index,
|
||||
place_at_origin: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
PortfolioMessage::InsertSvg {
|
||||
name,
|
||||
svg,
|
||||
@@ -1824,7 +1782,7 @@ impl PortfolioMessageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_file(path: &PathBuf, content: Vec<u8>) -> FileContent {
|
||||
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) {
|
||||
@@ -1836,18 +1794,7 @@ impl PortfolioMessageHandler {
|
||||
Ok(content) => FileContent::Svg(content),
|
||||
Err(_) => FileContent::Unsupported,
|
||||
},
|
||||
_ => {
|
||||
let format = image::guess_format(&content).unwrap_or_else(|_| image::ImageFormat::from_path(path).unwrap_or(image::ImageFormat::Png));
|
||||
match image::load_from_memory_with_format(&content, format) {
|
||||
Ok(image) => {
|
||||
// TODO: Handle Image formats with more than 8 bits per channel
|
||||
let image_data = image.to_rgba8();
|
||||
let image = Image::<Color>::from_image_data(image_data.as_raw(), image.width(), image.height());
|
||||
FileContent::Image(image)
|
||||
}
|
||||
Err(_) => FileContent::Unsupported,
|
||||
}
|
||||
}
|
||||
_ => FileContent::Image(content),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
9
editor/src/messages/portfolio/resource_upload/mod.rs
Normal file
9
editor/src/messages/portfolio/resource_upload/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
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};
|
||||
@@ -0,0 +1,14 @@
|
||||
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 },
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
use graphene_std::Color;
|
||||
use graphene_std::raster::Image;
|
||||
|
||||
/// Proportional share (0-1) for the document panel's side when splitting adjacent to non-document panels.
|
||||
const DOCUMENT_PANEL_SHARE: f64 = 0.8;
|
||||
/// Proportional share for each side when neither (or both) contain the document panel.
|
||||
@@ -793,8 +790,8 @@ pub enum FileContent {
|
||||
Document(String),
|
||||
/// A `.gdd` document container (archive bytes).
|
||||
GddDocument(Vec<u8>),
|
||||
/// A bitmap image.
|
||||
Image(Image<Color>),
|
||||
/// Any other file, expected to be a bitmap image.
|
||||
Image(Vec<u8>),
|
||||
/// An SVG file string.
|
||||
Svg(String),
|
||||
/// Any other unsupported/unrecognized file type.
|
||||
|
||||
@@ -33,6 +33,7 @@ pub use crate::messages::portfolio::document::resource::{ResourceMessage, Resour
|
||||
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::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,11 +5,11 @@ 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;
|
||||
use graphene_std::raster::BlendMode;
|
||||
use graphene_std::raster_types::Image;
|
||||
use graphene_std::text::{Font, TypesettingConfig};
|
||||
use graphene_std::vector::misc::ManipulatorPointId;
|
||||
use graphene_std::vector::style::{FillChoice, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, initial_gradient_transform_for_bounding_box};
|
||||
@@ -206,10 +206,15 @@ pub fn merge_points(document: &DocumentMessageHandler, layer: LayerNodeIdentifie
|
||||
responses.add(GraphOperationMessage::Vector { layer, modification_type });
|
||||
}
|
||||
|
||||
/// Create a new bitmap layer.
|
||||
pub fn new_image_layer(image: Image<Color>, id: NodeId, parent: LayerNodeIdentifier, responses: &mut VecDeque<Message>) -> LayerNodeIdentifier {
|
||||
/// 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 {
|
||||
let insert_index = 0;
|
||||
responses.add(GraphOperationMessage::NewBitmapLayer { id, image, parent, insert_index });
|
||||
responses.add(GraphOperationMessage::NewBitmapLayer {
|
||||
id,
|
||||
resource_id,
|
||||
parent,
|
||||
insert_index,
|
||||
});
|
||||
LayerNodeIdentifier::new_unchecked(id)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +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::prelude::*;
|
||||
use crate::messages::tool::tool_messages::tool_prelude::Key;
|
||||
use crate::messages::tool::utility_types::ToolType;
|
||||
@@ -242,11 +243,10 @@ impl EditorTestUtils {
|
||||
}
|
||||
|
||||
pub async fn create_raster_image(&mut self, image: graphene_std::raster::Image<Color>, mouse: Option<(f64, f64)>) {
|
||||
self.handle_message(PortfolioMessage::InsertImage {
|
||||
self.handle_message(ResourceUploadMessage::Upload {
|
||||
name: None,
|
||||
image,
|
||||
mouse,
|
||||
parent_and_insert_index: None,
|
||||
data: image.to_png().into(),
|
||||
target: UploadTarget::Layer { mouse, parent_and_insert_index: None },
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -103,6 +103,11 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
|
||||
editor.importFile(data.filename, data.content);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerUploadResource", async ({ filters }) => {
|
||||
const data = await upload(acceptStringFromFilters(filters), "data");
|
||||
editor.uploadResource(data.filename, data.content);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerSaveDocument", (data) => {
|
||||
downloadFile(data.name, data.content);
|
||||
});
|
||||
@@ -192,6 +197,7 @@ export function destroyPortfolioStore() {
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerFetchAndOpenDocument");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerOpen");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerImport");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerUploadResource");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerSaveDocument");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerSaveFile");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerExportImage");
|
||||
|
||||
@@ -85,9 +85,13 @@ export async function pasteFile(item: DataTransferItem, editor: EditorWrapper, m
|
||||
const file = item.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (file.type.startsWith("image/svg")) {
|
||||
const svg = await file.text();
|
||||
editor.pasteSvg(file.name, svg, mouse?.[0], mouse?.[1], insertParentId, insertIndex);
|
||||
} else if (editor.rasterImageExtensions().includes(extension)) {
|
||||
// Formats the editor decodes itself keep their original bytes instead of being rasterized by the browser
|
||||
editor.pasteImageFile(file.name, await file.bytes(), mouse?.[0], mouse?.[1], insertParentId, insertIndex);
|
||||
} else if (file.type.startsWith("image/")) {
|
||||
const imageData = await extractPixelData(file);
|
||||
editor.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, mouse?.[0], mouse?.[1], insertParentId, insertIndex);
|
||||
|
||||
@@ -20,10 +20,12 @@ mod editor_commands {
|
||||
use editor::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
|
||||
use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport;
|
||||
use editor::messages::portfolio::resource_upload::utility_types::UploadTarget;
|
||||
use editor::messages::portfolio::utility_types::PanelGroupId;
|
||||
use editor::messages::prelude::*;
|
||||
use editor::messages::tool::tool_messages::tool_prelude::WidgetId;
|
||||
use graph_craft::document::NodeId;
|
||||
use graphene_std::raster::Image;
|
||||
use graphene_std::raster::color::Color;
|
||||
use graphene_std::vector::style::FillChoice;
|
||||
use std::path::PathBuf;
|
||||
@@ -613,7 +615,7 @@ mod editor_commands {
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Pastes an image
|
||||
/// Pastes decoded RGBA8 pixels as an image layer, encoded as PNG for storage
|
||||
fn paste_image(
|
||||
name: Option<String>,
|
||||
image_data: Vec<u8>,
|
||||
@@ -625,7 +627,7 @@ mod editor_commands {
|
||||
insert_index: Option<usize>,
|
||||
) -> Message {
|
||||
let mouse = mouse_x.and_then(|x| mouse_y.map(|y| (x, y)));
|
||||
let image = graphene_std::raster::Image::from_image_data(&image_data, width, height);
|
||||
let data = Image::from_image_data(&image_data, width, height).to_png();
|
||||
|
||||
let parent_and_insert_index = if let (Some(insert_parent_id), Some(insert_index)) = (insert_parent_id, insert_index) {
|
||||
let insert_parent_id = NodeId(insert_parent_id);
|
||||
@@ -635,15 +637,39 @@ mod editor_commands {
|
||||
None
|
||||
};
|
||||
|
||||
PortfolioMessage::InsertImage {
|
||||
ResourceUploadMessage::Upload {
|
||||
name,
|
||||
image,
|
||||
mouse,
|
||||
parent_and_insert_index,
|
||||
data: data.into(),
|
||||
target: UploadTarget::Layer { mouse, parent_and_insert_index },
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Pastes an image file as an image layer, keeping its original encoding
|
||||
fn paste_image_file(name: Option<String>, data: Vec<u8>, mouse_x: Option<f64>, mouse_y: Option<f64>, insert_parent_id: Option<u64>, insert_index: Option<usize>) -> Message {
|
||||
let mouse = mouse_x.and_then(|x| mouse_y.map(|y| (x, y)));
|
||||
|
||||
let parent_and_insert_index = if let (Some(insert_parent_id), Some(insert_index)) = (insert_parent_id, insert_index) {
|
||||
let insert_parent_id = NodeId(insert_parent_id);
|
||||
let parent = LayerNodeIdentifier::new_unchecked(insert_parent_id);
|
||||
Some((parent, insert_index))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
ResourceUploadMessage::Upload {
|
||||
name,
|
||||
data: data.into(),
|
||||
target: UploadTarget::Layer { mouse, parent_and_insert_index },
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Hands the file picked for a requested resource upload to the editor
|
||||
fn upload_resource(name: String, data: Vec<u8>) -> Message {
|
||||
ResourceUploadMessage::ReceiveUpload { name: Some(name), data: data.into() }.into()
|
||||
}
|
||||
|
||||
/// Pastes an SVG given its string representation
|
||||
fn paste_svg(name: Option<String>, svg: String, mouse_x: Option<f64>, mouse_y: Option<f64>, insert_parent_id: Option<u64>, insert_index: Option<usize>) -> Message {
|
||||
let mouse = mouse_x.and_then(|x| mouse_y.map(|y| (x, y)));
|
||||
|
||||
@@ -240,6 +240,22 @@ impl EditorWrapper {
|
||||
cfg!(debug_assertions)
|
||||
}
|
||||
|
||||
/// The file extensions of raster images the editor decodes itself (web only; on desktop, dropped files are imported natively and this is never called)
|
||||
#[cfg(all(feature = "web", not(feature = "native")))]
|
||||
#[wasm_bindgen(js_name = rasterImageExtensions)]
|
||||
pub fn raster_image_extensions(&self) -> Vec<String> {
|
||||
editor::messages::portfolio::resource_upload::utility_types::RASTER_IMAGE_EXTENSIONS
|
||||
.iter()
|
||||
.map(|extension| extension.to_string())
|
||||
.collect()
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[wasm_bindgen(js_name = rasterImageExtensions)]
|
||||
pub fn raster_image_extensions(&self) -> Vec<String> {
|
||||
log::error!("rasterImageExtensions is unavailable on desktop, where dropped files are imported natively");
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Load persisted browser storage state (web only; on desktop, persistence is handled natively and this is never triggered)
|
||||
#[cfg(all(feature = "web", not(feature = "native")))]
|
||||
#[wasm_bindgen(js_name = loadPersistedState)]
|
||||
|
||||
@@ -42,6 +42,7 @@ macro_rules! for_each_item_type_default {
|
||||
$action!(Gradient);
|
||||
$action!(Artboard);
|
||||
$action!(String);
|
||||
$action!(Resource);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,7 +64,6 @@ macro_rules! for_each_list_type_default {
|
||||
macro_rules! for_each_bare_type_default {
|
||||
($action:ident) => {
|
||||
$action!(DocumentNode);
|
||||
$action!(Resource);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -251,13 +251,17 @@ pub fn empty_image(_: impl Ctx, transform: Item<DAffine2>, color: Item<Color>) -
|
||||
}
|
||||
|
||||
#[node_macro::node(category(""))]
|
||||
pub fn image<'a: 'n>(_: impl Ctx, resource: Item<Resource>) -> Item<Raster<CPU>> {
|
||||
pub fn image<'a: 'n>(
|
||||
_: impl Ctx,
|
||||
_primary: (),
|
||||
/// The image file to display.
|
||||
#[widget(ParsedWidgetOverride::Custom = "image_file")]
|
||||
resource: Item<Resource>,
|
||||
) -> Item<Raster<CPU>> {
|
||||
let resource = resource.into_element();
|
||||
let image_data = resource.as_ref();
|
||||
|
||||
let Some(image) = ::image::load_from_memory(image_data).ok() else {
|
||||
return Item::default();
|
||||
};
|
||||
let Some(image) = ::image::load_from_memory(image_data).ok() else { return Item::default() };
|
||||
let image = image.to_rgba32f();
|
||||
let image = Image {
|
||||
data: image.chunks(4).map(|pixel| Color::from_gamma_srgb_channels(pixel[0], pixel[1], pixel[2], pixel[3])).collect(),
|
||||
|
||||
Reference in New Issue
Block a user