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:
Keavon Chambers
2026-09-18 18:14:24 -07:00
committed by GitHub
parent cb30348215
commit f90bef159c
34 changed files with 774 additions and 180 deletions

View File

@@ -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 } => {

View File

@@ -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,

View File

@@ -107,6 +107,7 @@ pub enum DesktopWrapperMessage {
pub enum OpenFileDialogContext {
Open,
Import,
UploadResource,
}
pub enum SaveFileDialogContext {

View File

@@ -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,
},
});
}
},

View File

@@ -93,6 +93,9 @@ pub enum FrontendMessage {
TriggerImport {
filters: Vec<FileFilter>,
},
TriggerUploadResource {
filters: Vec<FileFilter>,
},
TriggerSaveDocument {
document_id: DocumentId,
name: String,

View File

@@ -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`

View File

@@ -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 {

View File

@@ -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,
},

View File

@@ -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);
}

View File

@@ -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, &[]);

View File

@@ -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| {

View File

@@ -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(&parameter_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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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)) => {

View File

@@ -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));
}
}

View File

@@ -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();

View File

@@ -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};

View File

@@ -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,

View File

@@ -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),
}
}

View 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};

View File

@@ -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 },
}

View File

@@ -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");
}
}

View File

@@ -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,
}

View File

@@ -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.

View File

@@ -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};

View File

@@ -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)
}

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -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);

View File

@@ -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)));

View File

@@ -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)]

View File

@@ -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);
};
}

View File

@@ -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(),