Unify file opening, importing, and pasting into a file ingest handler (#4548)

* Unify file and data ingest

* Code review

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Timon
2026-09-19 05:47:02 +00:00
committed by GitHub
parent f90bef159c
commit 7d1303f695
47 changed files with 855 additions and 980 deletions

View File

@@ -427,6 +427,7 @@ impl Dispatcher {
#[cfg(test)]
mod test {
use crate::messages::portfolio::ingest::utility_types::IngestAction;
pub use crate::test_utils::test_prelude::*;
#[tokio::test]
@@ -478,9 +479,11 @@ mod test {
"Demo artwork '{document_name}' has more than 1 line (remember to open and re-save it in Graphite)",
);
let responses = editor.editor.handle_message(PortfolioMessage::OpenFile {
path: file_name.into(),
content: document_serialized_content.bytes().collect(),
let responses = editor.editor.handle_message(IngestMessage::Ingest {
data: document_serialized_content.into_bytes(),
action: IngestAction::Open,
mime_type: String::new(),
path: Some(file_name.into()),
});
// Check if the graph renders

View File

@@ -5,14 +5,12 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions:
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface;
use crate::messages::portfolio::document::utility_types::nodes::SelectedNodes;
use crate::messages::portfolio::resource_upload::utility_types::UploadTarget;
use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils;
use crate::messages::tool::utility_types::ToolType;
use graph_craft::application_io::resource::{DataSource, ResourceHash};
use graph_craft::document::NodeId;
use graphene_std::Color;
use graphene_std::raster::Image;
use graphene_std::vector::misc::{BezierHandles, HandleId, point_to_dvec2, segment_to_handles};
use graphene_std::vector::{PointId, SegmentId, VectorModificationType};
use graphite_proc_macros::{ExtractField, message_handler_data};
@@ -52,24 +50,6 @@ impl MessageHandler<ClipboardMessage, ClipboardMessageContext<'_>> for Clipboard
responses.add(FrontendMessage::TriggerSelectionWrite { content: text });
}
}
ClipboardContentRaw::Svg(svg) => {
responses.add(PortfolioMessage::InsertSvg {
svg,
name: None,
mouse: None,
parent_and_insert_index: None,
});
}
ClipboardContentRaw::Image { data, width, height } => {
responses.add(ResourceUploadMessage::Upload {
name: None,
data: Image::from_image_data(&data, width, height).to_png().into(),
target: UploadTarget::Layer {
mouse: None,
parent_and_insert_index: None,
},
});
}
},
ClipboardMessage::ReadSelection { content, cut } => {
if let Some(text) = content {

View File

@@ -10,8 +10,6 @@ use graphene_std::vector::Vector;
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum ClipboardContentRaw {
Text(String),
Svg(String),
Image { data: Vec<u8>, width: u32, height: u32 },
}
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]

View File

@@ -1,7 +1,7 @@
use super::IconName;
use super::utility_types::{MouseCursorIcon, PersistedState};
use crate::messages::app_window::app_window_message_handler::AppWindowPlatform;
use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, FileFilter, RasterizedImage};
use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, FileDialogOptions, FileFilter, RasterizedImage};
use crate::messages::input_mapper::utility_types::misc::ActionShortcut;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::node_graph::utility_types::{
@@ -9,6 +9,7 @@ use crate::messages::portfolio::document::node_graph::utility_types::{
};
use crate::messages::portfolio::document::utility_types::nodes::{LayerPanelEntry, LayerStructureEntry};
use crate::messages::portfolio::document::utility_types::wires::{WirePath, WirePathUpdate};
use crate::messages::portfolio::ingest::utility_types::IngestAction;
use crate::messages::portfolio::utility_types::WorkspacePanelLayout;
use crate::messages::prelude::*;
use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary;
@@ -87,14 +88,9 @@ pub enum FrontendMessage {
commit_date: String,
},
TriggerDisplayThirdPartyLicensesDialog,
TriggerOpen {
filters: Vec<FileFilter>,
},
TriggerImport {
filters: Vec<FileFilter>,
},
TriggerUploadResource {
filters: Vec<FileFilter>,
TriggerBrowse {
options: FileDialogOptions,
action: IngestAction,
},
TriggerSaveDocument {
document_id: DocumentId,

View File

@@ -82,6 +82,7 @@ impl FileType {
FileFilter {
name: name.into(),
extensions: vec![self.extension().into()],
mime_types: Vec::new(),
}
}
}
@@ -117,4 +118,13 @@ pub struct RasterizedImage {
pub struct FileFilter {
pub name: String,
pub extensions: Vec<String>,
#[serde(rename = "mimeTypes")]
pub mime_types: Vec<String>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
pub struct FileDialogOptions {
pub filters: Vec<FileFilter>,
pub multiple: bool,
}

View File

@@ -444,8 +444,8 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping {
entry!(KeyDown(Tab); modifiers=[Control, Shift], action_dispatch=PortfolioMessage::PrevDocument),
entry!(KeyDown(KeyW); modifiers=[Accel], action_dispatch=PortfolioMessage::CloseActiveDocumentWithConfirmation),
entry!(KeyDown(KeyW); modifiers=[Accel, Alt], action_dispatch=PortfolioMessage::CloseAllDocumentsWithConfirmation),
entry!(KeyDown(KeyO); modifiers=[Accel], action_dispatch=PortfolioMessage::Open),
entry!(KeyDown(KeyI); modifiers=[Accel], action_dispatch=PortfolioMessage::Import),
entry!(KeyDown(KeyO); modifiers=[Accel], action_dispatch=IngestMessage::Open),
entry!(KeyDown(KeyI); modifiers=[Accel], action_dispatch=IngestMessage::Import),
entry!(KeyDown(KeyR); modifiers=[Alt], action_dispatch=PortfolioMessage::ToggleRulers),
entry!(KeyDown(KeyD); modifiers=[Alt], action_dispatch=PortfolioMessage::ToggleDataPanelOpen),
entry!(KeyDown(Enter); modifiers=[Alt], action_dispatch=PortfolioMessage::ToggleFocusDocument),

View File

@@ -123,8 +123,8 @@ impl LayoutHolder for MenuBarMessageHandler {
MenuListEntry::new("Open…")
.label("Open…")
.icon("Folder")
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::Open))
.on_commit(|_| PortfolioMessage::Open.into()),
.tooltip_shortcut(action_shortcut!(IngestMessageDiscriminant::Open))
.on_commit(|_| IngestMessage::Open.into()),
MenuListEntry::new("Open Demo Artwork…")
.label("Open Demo Artwork…")
.icon("Image")
@@ -163,8 +163,8 @@ impl LayoutHolder for MenuBarMessageHandler {
MenuListEntry::new("Import…")
.label("Import…")
.icon("FileImport")
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::Import))
.on_commit(|_| PortfolioMessage::Import.into())
.tooltip_shortcut(action_shortcut!(IngestMessageDiscriminant::Import))
.on_commit(|_| IngestMessage::Import.into())
.disabled(no_active_document),
MenuListEntry::new("Export…")
.label("Export…")

View File

@@ -7,10 +7,9 @@ use crate::messages::portfolio::document::data_panel::DataPanelMessage;
use crate::messages::portfolio::document::overlays::utility_types::{OverlayContext, OverlaysType};
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GridSnapping};
use crate::messages::portfolio::resource_upload::utility_types::ImageResource;
use crate::messages::portfolio::utility_types::PanelType;
use crate::messages::prelude::*;
use glam::{DAffine2, IVec2};
use glam::{DAffine2, IVec2, UVec2};
use graph_craft::document::NodeId;
use graphene_std::Appearance;
use graphene_std::raster::BlendMode;
@@ -116,7 +115,8 @@ pub enum DocumentMessage {
},
InsertImage {
name: Option<String>,
image: ImageResource,
data: Arc<[u8]>,
size: UVec2,
mouse: Option<(f64, f64)>,
parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>,
/// When true (file-open flow), place the image at the document origin so `WrapContentInArtboard`

View File

@@ -798,13 +798,14 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
}
DocumentMessage::InsertImage {
name,
image,
data,
size,
mouse,
parent_and_insert_index,
place_at_origin,
} => {
let layer_parent = self.new_layer_parent(true);
let image_size = DVec2::new(image.width as f64, image.height as f64);
let image_size = size.as_dvec2();
let mut transform = if place_at_origin {
// File-open flow: place at document origin without centering so `WrapContentInArtboard` can wrap it
@@ -827,7 +828,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
responses.add(DocumentMessage::StartTransaction);
let layer = graph_modification_utils::new_image_layer(image.resource_id, layer_node_id, layer_parent, responses);
let layer = graph_modification_utils::new_image_layer(data, layer_node_id, layer_parent, responses);
if let Some(name) = name {
responses.add(NodeGraphMessage::SetDisplayName {
@@ -1099,6 +1100,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
filters: vec![FileFilter {
name: "Graphite Document".into(),
extensions: vec![extension.into()],
mime_types: Vec::new(),
}],
content: content.into(),
})

View File

@@ -3,13 +3,13 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye
use crate::messages::portfolio::document::utility_types::network_interface::NodeTemplate;
use crate::messages::prelude::*;
use glam::{DAffine2, DVec2};
use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::NodeId;
use graphene_std::Color;
use graphene_std::raster::BlendMode;
use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, PaintOrder, Stroke};
use graphene_std::vector::{Gradient, VectorModificationType};
use std::sync::Arc;
#[impl_message(Message, DocumentMessage, GraphOperation)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
@@ -141,7 +141,7 @@ pub enum GraphOperationMessage {
},
NewBitmapLayer {
id: NodeId,
resource_id: ResourceId,
data: Arc<[u8]>,
parent: LayerNodeIdentifier,
insert_index: usize,
},

View File

@@ -267,15 +267,10 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
responses.add_front(NodeGraphMessage::SelectedNodesSet { nodes: vec![id] });
responses.add(NodeGraphMessage::RunDocumentGraph);
}
GraphOperationMessage::NewBitmapLayer {
id,
resource_id,
parent,
insert_index,
} => {
GraphOperationMessage::NewBitmapLayer { id, data, parent, insert_index } => {
let mut modify_inputs = ModifyInputsContext::new(network_interface, responses);
let layer = modify_inputs.create_layer(id);
modify_inputs.insert_image_data(resource_id, layer);
modify_inputs.insert_image_data(data, layer);
network_interface.move_layer_to_stack(layer, parent, insert_index, &[]);
responses.add(NodeGraphMessage::RunDocumentGraph);
}

View File

@@ -19,6 +19,7 @@ use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientIn
use graphene_std::vector::{Gradient, GradientRamp, Vector, VectorModification, VectorModificationType};
use graphene_std::{Artboard, Color, Graphic};
use kurbo::BezPath;
use std::sync::Arc;
#[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
pub enum TransformIn {
@@ -269,11 +270,14 @@ impl<'a> ModifyInputsContext<'a> {
self.network_interface.set_chain_position(node_id, &[]);
}
pub fn insert_image_data(&mut self, resource_id: ResourceId, layer: LayerNodeIdentifier) {
pub fn insert_image_data(&mut self, data: Arc<[u8]>, layer: LayerNodeIdentifier) {
let transform = resolve_proto_node_type(graphene_std::transform_nodes::transform::IDENTIFIER)
.expect("Transform node does not exist")
.default_node_template();
let resource_id = ResourceId::new();
self.responses.add(ResourceMessage::StoreEmbedded { resource_id, data });
let image_node = resolve_proto_node_type(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER)
.expect("Image node does not exist")
.node_template_input_override([None, Some(NodeInput::value(TaggedValue::Resource(resource_id), false))]);

View File

@@ -7,7 +7,7 @@ use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::utility_types::network_interface::{
InputMetadata, NodeNetworkInterface, NodeNetworkTemplate, NodeTemplate, NodeTemplateImplementation, NodeTypePersistentMetadata, Vec2InputSettings, WidgetOverride,
};
use crate::messages::portfolio::resource_upload::utility_types::ResourceFileKind;
use crate::messages::portfolio::ingest::utility_types::TypeFilter;
use crate::messages::prelude::{DocumentId, FontsMessage, FontsMessageHandler, Message, ResourceMessageHandler, Responses};
use crate::node_graph_executor::NodeGraphExecutor;
use glam::DVec2;
@@ -1466,7 +1466,7 @@ fn static_input_properties() -> InputProperties {
map.insert(
"image_file".to_string(),
Box::new(|node_id, index, context| {
let widgets = node_properties::resource_widget(ParameterWidgetsInfo::at_index(node_id, index, true, context), ResourceFileKind::RasterImage);
let widgets = node_properties::resource_widget(ParameterWidgetsInfo::at_index(node_id, index, true, context), vec![TypeFilter::raster()]);
Ok(vec![LayoutGroup::row(widgets)])
}),
);

View File

@@ -7,7 +7,7 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions:
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface};
use crate::messages::portfolio::fonts::utility_types::FontCatalogStyle;
use crate::messages::portfolio::resource_upload::utility_types::{ResourceFileKind, UploadTarget};
use crate::messages::portfolio::ingest::utility_types::TypeFilter;
use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils;
use choice::enum_choice;
@@ -342,7 +342,7 @@ pub(crate) fn property_from_type(
Some(x) if id_is::<Footprint>(x) => footprint_widget(default_info, &mut extra_widgets),
Some(x) if id_is::<Box<VectorModification>>(x) => vector_modification_widget(default_info).into(),
Some(x) if id_is::<Image<Color>>(x) => image_data_widget(default_info).into(),
Some(x) if id_is::<Resource>(x) => resource_widget(default_info, ResourceFileKind::Any).into(),
Some(x) if id_is::<Resource>(x) => resource_widget(default_info, Vec::new()).into(),
// ===============================
// MANUALLY IMPLEMENTED ENUM TYPES
// ===============================
@@ -1290,8 +1290,8 @@ pub fn font_widget(parameter_widgets_info: ParameterWidgetsInfo) -> LayoutGroup
font_widgets.into_iter().chain(style_widgets.unwrap_or_default()).collect::<Vec<_>>().into()
}
/// A dropdown of the document's uploaded files, led by "None" and a "Browse…" entry that uploads another file of the given kind.
pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, kind: ResourceFileKind) -> Vec<WidgetInstance> {
/// A dropdown of the document's uploaded files, led by "None" and a "Browse…" entry that uploads another file matching the given filters.
pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, filters: Vec<TypeFilter>) -> Vec<WidgetInstance> {
let mut widgets = start_widgets(&parameter_widgets_info);
let Some(input) = parameter_widgets_info.input() else {
@@ -1304,7 +1304,6 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, kind: Resou
_ => return widgets,
};
// Fonts have their own picker, so only uploaded files are listed, labeled by hash and user count until resources carry names
let ParameterWidgetsInfo {
document_id,
node_id,
@@ -1313,14 +1312,17 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, kind: Resou
network_interface,
..
} = parameter_widgets_info;
let user_counts = network_interface.resource_user_counts();
let use_counts = network_interface.collect_resources_use_counts();
// This is a heuristic to filter for image resources and will break once other data types are loaded.
// TODO: Add a proper way to filter for image resources.
let mut files: Vec<(ResourceId, String, String)> = resources
.registry
.resolved()
.filter(|info| !info.sources.iter().any(|source| matches!(source, DataSource::Font { .. })))
.map(|info| {
let hash = info.hash.map(|hash| hash.to_string()[..8].to_string()).unwrap_or_default();
let users = user_counts.get(&info.id).copied().unwrap_or(0);
let users = use_counts.get(&info.id).copied().unwrap_or(0);
let tooltip_description = match users {
0 => "Not used by any node input. This resource will be dropped upon document reload.".to_string(),
users => format!("Used by {users} node input{}.", if users == 1 { "" } else { "s" }),
@@ -1359,13 +1361,11 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, kind: Resou
.tooltip_description("Pick a file from disk to use for this input.")
.on_update(|_| Message::NoOp)
.on_commit(move |_| {
ResourceUploadMessage::RequestUpload {
target: UploadTarget::NodeInput {
document_id,
node_id,
input_index: index,
kind,
},
IngestMessage::SetResourceInput {
document_id,
node_id,
input_index: index,
filters: filters.clone(),
}
.into()
});

View File

@@ -15,7 +15,7 @@ use crate::messages::portfolio::document::document_message_handler::DocumentMess
use crate::messages::portfolio::document::utility_types::misc::GroupFolderType;
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{StorageMetadataView, build_interface_from_storage};
use crate::messages::portfolio::resource_upload::utility_types::UploadTarget;
use crate::messages::portfolio::ingest::utility_types::IngestAction;
use crate::test_utils::test_prelude::*;
use graphene_std::NodeParameter;
use graphene_std::vector::style::RenderMode;
@@ -491,22 +491,19 @@ async fn live_undo_new_document_draw_rect() {
assert_eq!(editor.active_document().network_interface.document_network(), &before_rect, "undo should restore the pre-rect network");
}
fn paste_named_image() -> IngestMessage {
IngestMessage::Ingest {
data: Image::new(2, 2, Color::WHITE).to_png(),
action: IngestAction::Paste,
mime_type: String::new(),
path: Some("pasted.png".into()),
}
}
/// Pasting an image is one user action and must be one undo step: the paste handler brackets the layer add,
/// name set, reparent, and transform in a single transaction. Were the name set to open its own nested
/// transaction (a historical wart), the first undo would revert only the name and leave the layer behind, so
/// this asserts the layer count returns to its pre-paste value after exactly one undo.
/// Pastes a 2x2 image as a named layer.
fn paste_named_image() -> ResourceUploadMessage {
ResourceUploadMessage::Upload {
name: Some("pasted".into()),
data: Image::new(2, 2, Color::WHITE).to_png().into(),
target: UploadTarget::Layer {
mouse: None,
parent_and_insert_index: None,
},
}
}
#[tokio::test]
async fn paste_image_with_name_is_one_undo_step() {
let mut editor = EditorTestUtils::create();
@@ -534,6 +531,7 @@ async fn paste_image_with_name_is_one_undo_step() {
/// Choosing "None" in the Image node's file picker leaves the empty-resource placeholder, which must still render.
#[tokio::test]
async fn image_node_with_no_file_still_evaluates() {
use graph_craft::application_io::resource::Resource;
use graph_craft::document::DocumentNodeImplementation;
use graph_craft::document::value::TaggedValue;
use graph_craft::item;
@@ -555,7 +553,7 @@ async fn image_node_with_no_file_still_evaluates() {
.handle_message(NodeGraphMessage::SetInputValue {
node_id: image_node_id,
input_index: graphene_std::raster_nodes::std_nodes::image::ResourceInput::INDEX,
value: TaggedValue::TypeDefault(item!(graph_craft::application_io::resource::Resource)).into(),
value: TaggedValue::TypeDefault(item!(Resource)).into(),
})
.await;
@@ -695,9 +693,11 @@ async fn demo_artwork_edit_autosaves_and_round_trips() {
// Open a real demo artwork through the normal open path and let it render.
let content = std::fs::read_to_string("../demo-artwork/changing-seasons.graphite").expect("read demo artwork");
editor
.handle_message(PortfolioMessage::OpenFile {
path: "changing-seasons.graphite".into(),
content: content.bytes().collect(),
.handle_message(IngestMessage::Ingest {
data: content.into_bytes(),
action: IngestAction::Open,
mime_type: String::new(),
path: Some("changing-seasons.graphite".into()),
})
.await;

View File

@@ -311,11 +311,12 @@ impl NodeNetworkInterface {
}
pub fn collect_used_resources(&self, target: &mut HashSet<ResourceId>) {
collect_network_resources(self.document_network(), target);
visit_network_resources(self.document_network(), &mut |id| {
target.insert(id);
});
}
/// How many value inputs across the document reference each resource, with no entry for a resource nothing uses.
pub fn resource_user_counts(&self) -> HashMap<ResourceId, usize> {
pub fn collect_resources_use_counts(&self) -> HashMap<ResourceId, usize> {
let mut counts = HashMap::new();
visit_network_resources(self.document_network(), &mut |id| *counts.entry(id).or_insert(0) += 1);
counts

View File

@@ -283,12 +283,16 @@ impl NodeTemplateImplementation {
/// Collects resource IDs referenced by a template and its nested networks.
pub fn collect_template_resources(template: &NodeTemplate, out: &mut HashSet<ResourceId>) {
for input in &template.inputs {
collect_input_resource(input, out);
visit_input_resource(input, &mut |id| {
out.insert(id);
});
}
if let NodeTemplateImplementation::Network(network_template) = &template.implementation {
for export in &network_template.exports {
collect_input_resource(export, out);
visit_input_resource(export, &mut |id| {
out.insert(id);
});
}
for nested_template in network_template.nodes.values() {
collect_template_resources(nested_template, out);

View File

@@ -865,27 +865,6 @@ pub(crate) enum SoleDependentStep {
Escape,
}
pub(crate) fn collect_network_resources(network: &NodeNetwork, out: &mut HashSet<ResourceId>) {
visit_network_resources(network, &mut |id| {
out.insert(id);
});
}
/// Collects resource IDs referenced by a node and its nested networks.
pub fn collect_node_resources(node: &DocumentNode, out: &mut HashSet<ResourceId>) {
visit_node_resources(node, &mut |id| {
out.insert(id);
});
}
/// Records the resource ID held by a value input, covering node inputs and export slots alike.
pub(crate) fn collect_input_resource(input: &NodeInput, out: &mut HashSet<ResourceId>) {
visit_input_resource(input, &mut |id| {
out.insert(id);
});
}
/// Calls `visit` once per value input holding a resource ID across a network and its nested networks.
pub(crate) fn visit_network_resources(network: &NodeNetwork, visit: &mut impl FnMut(ResourceId)) {
for export in &network.exports {
visit_input_resource(export, visit);
@@ -895,7 +874,7 @@ pub(crate) fn visit_network_resources(network: &NodeNetwork, visit: &mut impl Fn
}
}
fn visit_node_resources(node: &DocumentNode, visit: &mut impl FnMut(ResourceId)) {
pub(crate) fn visit_node_resources(node: &DocumentNode, visit: &mut impl FnMut(ResourceId)) {
for input in &node.inputs {
visit_input_resource(input, visit);
}
@@ -904,7 +883,7 @@ fn visit_node_resources(node: &DocumentNode, visit: &mut impl FnMut(ResourceId))
}
}
fn visit_input_resource(input: &NodeInput, visit: &mut impl FnMut(ResourceId)) {
pub(crate) fn visit_input_resource(input: &NodeInput, visit: &mut impl FnMut(ResourceId)) {
if let NodeInput::Value { tagged_value, .. } = input
&& let TaggedValue::Resource(id) = &**tagged_value
{
@@ -916,6 +895,38 @@ fn visit_input_resource(input: &NodeInput, visit: &mut impl FnMut(ResourceId)) {
mod tests {
use super::*;
#[test]
fn resource_visits_reach_nested_networks_and_exports() {
let shared = ResourceId::from(1);
let uses = |id: ResourceId| NodeInput::value(TaggedValue::Resource(id), false);
let node = |inputs: Vec<NodeInput>| DocumentNode { inputs, ..Default::default() };
let inner = NodeNetwork {
exports: vec![uses(shared)],
nodes: [(NodeId(2), node(vec![uses(shared)]))].into_iter().collect(),
..Default::default()
};
let outer = NodeNetwork {
nodes: [
(NodeId(1), node(vec![uses(shared), uses(ResourceId::from(2))])),
(
NodeId(3),
DocumentNode {
implementation: DocumentNodeImplementation::Network(inner),
..Default::default()
},
),
]
.into_iter()
.collect(),
..Default::default()
};
let mut visits = Vec::new();
visit_network_resources(&outer, &mut |id| visits.push(id));
assert_eq!(visits.iter().filter(|id| **id == shared).count(), 3);
assert_eq!(visits.len(), 4);
}
#[test]
fn port_click_targets_are_clickable_at_their_center() {
let center = DVec2::new(100., 50.);
@@ -930,31 +941,4 @@ mod tests {
assert_eq!(ports.clicked_output_port_from_point(center + DVec2::new(200., 0.)), Some(0));
assert_eq!(ports.clicked_output_port_from_point(center), None);
}
#[test]
fn resource_visits_count_every_referencing_input_including_nested_networks() {
let shared = ResourceId::from(1);
let other = ResourceId::from(2);
let uses = |id: ResourceId| NodeInput::value(TaggedValue::Resource(id), false);
let node = |inputs: Vec<NodeInput>| DocumentNode { inputs, ..Default::default() };
let inner = NodeNetwork {
exports: vec![uses(shared)],
nodes: [(NodeId(2), node(vec![uses(shared)]))].into_iter().collect(),
..Default::default()
};
let nested = DocumentNode {
implementation: DocumentNodeImplementation::Network(inner),
..Default::default()
};
let outer = NodeNetwork {
nodes: [(NodeId(1), node(vec![uses(shared), uses(other)])), (NodeId(3), nested)].into_iter().collect(),
..Default::default()
};
let mut counts = HashMap::new();
visit_network_resources(&outer, &mut |id| *counts.entry(id).or_insert(0) += 1);
assert_eq!(counts.get(&shared), Some(&3), "every referencing input counts, nested networks included");
assert_eq!(counts.get(&other), Some(&1));
}
}

View File

@@ -10,7 +10,7 @@ use glam::{DVec2, IVec2};
use graph_craft::application_io::resource::{DataSource, Resource, ResourceHash, ResourceId};
use graph_craft::document::DocumentNode;
use graph_craft::document::{DocumentNodeImplementation, NodeInput, value::TaggedValue};
use graph_craft::{Type, item, list};
use graph_craft::{Type, concrete, item, list};
use graphene_std::Color;
use graphene_std::ParameterRef;
use graphene_std::ProtoNodeIdentifier;
@@ -2877,6 +2877,24 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
}
}
// A `Resource` input disconnected before `Resource` became an item-ranked type default stored the bare `TypeDefault(Resource)`, which no longer unwraps to an `Item<Resource>` default
if let Some(current_node) = document.network_interface.document_node(node_id, network_path) {
let bare_resource_inputs: Vec<(usize, bool)> = current_node
.inputs
.iter()
.enumerate()
.filter_map(|(index, input)| match input {
NodeInput::Value { tagged_value, exposed } if matches!(&**tagged_value, TaggedValue::TypeDefault(stored_type) if *stored_type == concrete!(Resource)) => Some((index, *exposed)),
_ => None,
})
.collect();
for (index, exposed) in bare_resource_inputs {
document
.network_interface
.set_input(&InputConnector::node_at_index(*node_id, index), NodeInput::type_default(item!(Resource), exposed), network_path);
}
}
// ==================================
// PUT ALL MIGRATIONS ABOVE THIS LINE
// ==================================
@@ -3137,31 +3155,53 @@ mod tests {
for legacy_input in legacy_inputs {
let image_id = NodeId(1);
let mut document = DocumentMessageHandler::default();
let image_template = |inputs| NodeTemplate {
let image_template = NodeTemplate {
implementation: NodeTemplateImplementation::ProtoNode(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER),
inputs,
inputs: vec![NodeInput::value(TaggedValue::None, false)],
..Default::default()
};
document.network_interface.insert_node(upstream_id, image_template(vec![]), &[]);
document.network_interface.insert_node(image_id, image_template(vec![NodeInput::value(TaggedValue::None, false)]), &[]);
document.network_interface.insert_node(upstream_id, NodeTemplate::default(), &[]);
document.network_interface.insert_node(image_id, image_template, &[]);
document.network_interface.set_input(&InputConnector::node_at_index(image_id, 0), legacy_input.clone(), &[]);
document_migration_upgrades(&mut document, false);
let image_node = &document.network_interface.document_network().nodes[&image_id];
assert_eq!(image_node.inputs.len(), 2, "the image node should gain its placeholder primary input");
match (&legacy_input, &image_node.inputs[1]) {
(NodeInput::Node { .. }, migrated) => assert_eq!(migrated.as_node(), Some(upstream_id), "the wire should sit at input 1"),
(_, migrated) => {
let migrated = &image_node.inputs[1];
match legacy_input.as_value() {
None => assert_eq!(migrated.as_node(), Some(upstream_id), "the wire should sit at input 1"),
Some(legacy_value) => {
let Some(TaggedValue::Resource(stored)) = migrated.as_value() else {
panic!("the file should sit at input 1")
};
assert!(document.resources.registry.contains(stored) || *stored == resource_id, "the stored file should be the legacy one");
match legacy_value {
TaggedValue::Resource(legacy) => assert_eq!(stored, legacy, "the stored resource should be kept"),
_ => assert!(document.resources.registry.contains(stored), "the embedded pixels should be stored as a new resource"),
}
}
}
}
}
#[test]
fn bare_resource_type_defaults_become_item_defaults() {
let node_id = NodeId(1);
let mut document = DocumentMessageHandler::default();
let image_template = NodeTemplate {
implementation: NodeTemplateImplementation::ProtoNode(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER),
inputs: vec![NodeInput::value(TaggedValue::None, false), NodeInput::value(TaggedValue::TypeDefault(concrete!(Resource)), true)],
..Default::default()
};
document.network_interface.insert_node(node_id, image_template, &[]);
document_migration_upgrades(&mut document, false);
let migrated = &document.network_interface.document_network().nodes[&node_id].inputs[1];
assert_eq!(migrated.as_value(), Some(&TaggedValue::TypeDefault(item!(Resource))), "the bare default should become the item default");
assert!(matches!(migrated, NodeInput::Value { exposed: true, .. }), "the exposed flag should be kept");
}
#[test]
fn test_no_duplicate_node_replacements() {
let mut hashmap = HashMap::<ProtoNodeIdentifier, u32>::new();

View File

@@ -0,0 +1,28 @@
use super::utility_types::{IngestAction, TypeFilter};
use crate::messages::prelude::*;
use graph_craft::document::NodeId;
use std::path::PathBuf;
#[impl_message(Message, PortfolioMessage, Ingest)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum IngestMessage {
Ingest {
data: Vec<u8>,
action: IngestAction,
mime_type: String,
path: Option<PathBuf>,
},
Browse {
filters: Vec<TypeFilter>,
multiple: bool,
action: IngestAction,
},
Open,
Import,
SetResourceInput {
document_id: DocumentId,
node_id: NodeId,
input_index: usize,
filters: Vec<TypeFilter>,
},
}

View File

@@ -0,0 +1,317 @@
use super::utility_types::{DataType, IngestAction, TypeFilter, decoded_image_size};
use crate::messages::frontend::utility_types::{FileDialogOptions, FileFilter};
use crate::messages::prelude::*;
use glam::IVec2;
use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue;
#[derive(ExtractField)]
pub struct IngestMessageContext {
pub document_open: bool,
}
#[derive(Debug, Default, ExtractField)]
pub struct IngestMessageHandler {}
#[message_handler_data]
impl MessageHandler<IngestMessage, IngestMessageContext> for IngestMessageHandler {
fn process_message(&mut self, message: IngestMessage, responses: &mut VecDeque<Message>, context: IngestMessageContext) {
match message {
IngestMessage::Open => responses.add(IngestMessage::Browse {
filters: vec![TypeFilter::documents(), TypeFilter::image()],
multiple: true,
action: IngestAction::Open,
}),
IngestMessage::Import => responses.add(IngestMessage::Browse {
filters: vec![TypeFilter::image()],
multiple: false,
action: IngestAction::Import,
}),
IngestMessage::SetResourceInput {
document_id,
node_id,
input_index,
filters,
} => {
let accepted_types = filters.iter().flat_map(|filter| filter.types.iter().copied()).collect();
responses.add(IngestMessage::Browse {
filters,
multiple: false,
action: IngestAction::ResourceInput {
document_id,
node_id,
input_index: input_index as u32,
accepted_types,
},
});
}
IngestMessage::Browse { filters, multiple, action } => responses.add(FrontendMessage::TriggerBrowse {
options: FileDialogOptions {
filters: filters.into_iter().map(FileFilter::from).collect(),
multiple,
},
action,
}),
IngestMessage::Ingest { data, action, mime_type, path } => {
let data_type = DataType::detect(&data, &mime_type, path.as_deref());
let placement = match action {
IngestAction::ResourceInput {
document_id,
node_id,
input_index,
accepted_types,
} => {
if !is_accepted(&data, data_type, &accepted_types) {
responses.add(DialogMessage::DisplayDialogError {
title: "Unsupported file".into(),
description: "This file is not a type that this input accepts.".into(),
});
return;
}
// The file goes to the document that asked for it, which may no longer be active or open once the dialog closes
let resource_id = ResourceId::new();
let messages = [
DocumentMessage::AddTransaction,
DocumentMessage::Resource(ResourceMessage::StoreEmbedded { resource_id, data: data.into() }),
DocumentMessage::NodeGraph(NodeGraphMessage::SetInputValue {
node_id,
input_index: input_index as usize,
value: TaggedValue::Resource(resource_id).into(),
}),
];
for message in messages {
responses.add(PortfolioMessage::DocumentPassMessage { document_id, message });
}
return;
}
IngestAction::Open => None,
IngestAction::Import | IngestAction::Paste => Some((None, None)),
IngestAction::DropOnCanvas { mouse } => Some((Some(mouse), None)),
IngestAction::DropOnLayers { parent, insert_index } => Some((None, Some((parent, insert_index as usize)))),
}
.filter(|_| context.document_open);
let name = path.as_ref().and_then(|path| path.file_stem()).map(|stem| stem.to_string_lossy().into_owned());
let document_path = path.filter(|path| path.is_absolute());
let (mouse, parent_and_insert_index) = placement.unwrap_or_default();
let place_at_origin = placement.is_none();
let (insert, artboard_canvas) = match data_type {
DataType::GraphiteLegacy => {
let Ok(document_serialized_content) = String::from_utf8(data) else {
return unsupported(responses);
};
responses.add(PortfolioMessage::OpenDocumentFile {
document_name: name,
document_path,
document_serialized_content,
});
return;
}
DataType::Gdd => {
responses.add(PortfolioMessage::OpenGddDocument {
document_name: name,
document_path,
content: data,
});
return;
}
DataType::Svg => {
let Ok(svg) = String::from_utf8(data) else { return unsupported(responses) };
let artboard_canvas = place_at_origin.then(|| svg_canvas(&svg)).flatten();
let insert = DocumentMessage::InsertSvg {
name: name.clone(),
svg,
mouse,
parent_and_insert_index,
place_at_origin,
};
(insert, artboard_canvas)
}
DataType::Raster(_) => {
let Some(size) = decoded_image_size(&data) else { return unsupported(responses) };
let insert = DocumentMessage::InsertImage {
name: name.clone(),
data: data.into(),
size: size.into(),
mouse,
parent_and_insert_index,
place_at_origin,
};
(insert, None)
}
DataType::Unknown => return unsupported(responses),
};
if !place_at_origin {
responses.add(insert);
return;
}
responses.add(PortfolioMessage::NewDocumentWithName { name: name.unwrap_or_default() });
responses.add(insert);
responses.add(DeferMessage::AfterGraphRun {
messages: vec![
DocumentMessage::WrapContentInArtboard {
place_artboard_at_origin: true,
artboard_canvas,
}
.into(),
],
});
responses.add(DeferMessage::AfterNavigationReady {
messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()],
});
}
}
}
fn actions(&self) -> ActionList {
actions!(IngestMessageDiscriminant;)
}
}
fn unsupported(responses: &mut VecDeque<Message>) {
responses.add(DialogMessage::DisplayDialogError {
title: "Unsupported format".into(),
description: "This file is not a supported document or image format.".into(),
});
}
/// Whether a node input takes the file, where an image must also fully decode.
fn is_accepted(data: &[u8], data_type: DataType, accepted_types: &[DataType]) -> bool {
if accepted_types.is_empty() {
return true;
}
accepted_types.contains(&data_type) && (!matches!(data_type, DataType::Raster(_)) || decoded_image_size(data).is_some())
}
// The viewBox preserves the full canvas rather than the tighter bounding box of the rendered content
fn svg_canvas(svg: &str) -> Option<(IVec2, IVec2)> {
usvg::roxmltree::Document::parse(svg)
.ok()
.and_then(|document| {
let numbers: Vec<f64> = document
.root_element()
.attribute("viewBox")?
.split(|character: char| character.is_ascii_whitespace() || character == ',')
.filter_map(|number| number.parse().ok())
.collect();
let [x, y, width, height, ..] = numbers[..] else { return None };
Some((IVec2::new(x.round() as i32, y.round() as i32), IVec2::new(width.round() as i32, height.round() as i32)))
})
.or_else(|| {
let size = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?.size();
Some((IVec2::ZERO, IVec2::new(size.width().round() as i32, size.height().round() as i32)))
})
}
#[cfg(test)]
mod tests {
use super::*;
use graph_craft::document::NodeId;
use graphene_std::Color;
use graphene_std::raster::Image;
const REQUESTING_DOCUMENT: DocumentId = DocumentId(3);
fn ingest(data: &[u8], action: IngestAction, document_open: bool) -> VecDeque<Message> {
let mut responses = VecDeque::new();
let message = IngestMessage::Ingest {
data: data.into(),
action,
mime_type: String::new(),
path: None,
};
IngestMessageHandler::default().process_message(message, &mut responses, IngestMessageContext { document_open });
responses
}
fn resource_input(accepted_types: Vec<DataType>) -> IngestAction {
IngestAction::ResourceInput {
document_id: REQUESTING_DOCUMENT,
node_id: NodeId(7),
input_index: 1,
accepted_types,
}
}
fn stored_resource(message: &Message) -> Option<ResourceId> {
match message {
Message::Portfolio(PortfolioMessage::DocumentPassMessage {
document_id: REQUESTING_DOCUMENT,
message: DocumentMessage::Resource(ResourceMessage::StoreEmbedded { resource_id, .. }),
}) => Some(*resource_id),
_ => None,
}
}
#[test]
fn resource_input_stores_the_file_and_assigns_it_in_the_requesting_document() {
let responses = ingest(b"any bytes", resource_input(Vec::new()), true);
let assigned = responses.iter().find_map(|message| match message {
Message::Portfolio(PortfolioMessage::DocumentPassMessage {
document_id: REQUESTING_DOCUMENT,
message: DocumentMessage::NodeGraph(NodeGraphMessage::SetInputValue { node_id, input_index, value }),
}) => Some((*node_id, *input_index, value.clone())),
_ => None,
});
let stored = responses.iter().find_map(stored_resource).expect("the file should be stored as a resource");
assert_eq!(assigned, Some((NodeId(7), 1, TaggedValue::Resource(stored).into())));
assert!(
responses.iter().all(|message| matches!(message, Message::Portfolio(PortfolioMessage::DocumentPassMessage { .. }))),
"nothing should reach whichever document happens to be active"
);
}
#[test]
fn resource_input_rejects_a_file_outside_its_types_before_storing_it() {
let png = Image::new(8, 8, Color::WHITE).to_png();
let raster = TypeFilter::raster().types;
assert!(ingest(&png, resource_input(raster.clone()), true).iter().any(|message| stored_resource(message).is_some()));
// Text, an SVG, and an image cut off after its intact header
for rejected in [b"not an image".as_slice(), b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>".as_slice(), &png[..40]] {
let responses = ingest(rejected, resource_input(raster.clone()), true);
assert_eq!(responses.len(), 1, "a rejected file should not become a resource");
assert!(matches!(responses[0], Message::Dialog(DialogMessage::DisplayDialogError { .. })), "the user should be told why");
}
}
#[test]
fn truncated_image_only_shows_a_dialog() {
let png = Image::new(8, 8, Color::WHITE).to_png();
let responses = ingest(&png[..40], IngestAction::Paste, true);
assert_eq!(responses.len(), 1);
assert!(matches!(responses[0], Message::Dialog(DialogMessage::DisplayDialogError { .. })));
}
#[test]
fn unsupported_file_only_shows_a_dialog() {
let responses = ingest(b"just text", IngestAction::Open, true);
assert_eq!(responses.len(), 1);
assert!(matches!(responses[0], Message::Dialog(DialogMessage::DisplayDialogError { .. })));
}
#[test]
fn image_without_a_document_opens_one() {
let png = Image::new(1, 1, Color::WHITE).to_png();
let responses = ingest(&png, IngestAction::Paste, false);
assert!(matches!(responses[0], Message::Portfolio(PortfolioMessage::NewDocumentWithName { .. })));
assert!(
responses
.iter()
.any(|message| matches!(message, Message::Portfolio(PortfolioMessage::Document(DocumentMessage::InsertImage { place_at_origin: true, .. }))))
);
let responses = ingest(&png, IngestAction::Paste, true);
assert!(matches!(
responses[0],
Message::Portfolio(PortfolioMessage::Document(DocumentMessage::InsertImage { place_at_origin: false, .. }))
));
}
}

View File

@@ -0,0 +1,9 @@
mod ingest_message;
mod ingest_message_handler;
pub mod utility_types;
#[doc(inline)]
pub use ingest_message::{IngestMessage, IngestMessageDiscriminant};
#[doc(inline)]
pub use ingest_message_handler::{IngestMessageContext, IngestMessageHandler};

View File

@@ -0,0 +1,221 @@
use crate::consts::{FILE_EXTENSION, GDD_FILE_EXTENSION};
use crate::messages::frontend::utility_types::FileFilter;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::prelude::DocumentId;
use document_container::archive::ArchiveFormat;
use graph_craft::document::NodeId;
use image::ImageFormat;
use std::ffi::OsStr;
use std::path::Path;
/// How many leading bytes are inspected to recognize a text format.
const SNIFFED_TEXT_LENGTH: usize = 4096;
/// The pixel size of a file that fully decodes as a raster image.
pub fn decoded_image_size(data: &[u8]) -> Option<(u32, u32)> {
image::load_from_memory(data).ok().map(|image| (image.width(), image.height()))
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
pub enum IngestAction {
Open,
Import,
Paste,
DropOnCanvas {
mouse: (f64, f64),
},
DropOnLayers {
parent: LayerNodeIdentifier,
insert_index: u32,
},
ResourceInput {
document_id: DocumentId,
node_id: NodeId,
input_index: u32,
/// The types this input takes, where none means any file.
#[cfg_attr(feature = "wasm", tsify(type = "unknown"))]
accepted_types: Vec<DataType>,
},
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DataType {
GraphiteLegacy,
Gdd,
Svg,
Raster(ImageFormat),
#[default]
Unknown,
}
impl DataType {
/// The content decides, and the MIME type then the file name only settle what it leaves unknown.
pub fn detect(data: &[u8], mime_type: &str, path: Option<&Path>) -> Self {
match Self::from_content(data) {
Self::Unknown => match Self::from_mime(mime_type) {
Self::Unknown => path.map_or(Self::Unknown, Self::from_path),
data_type => data_type,
},
data_type => data_type,
}
}
pub fn from_mime(mime: &str) -> Self {
match mime.to_ascii_lowercase().as_str() {
"application/graphite+json" => Self::GraphiteLegacy,
"application/vnd.graphite.document" => Self::Gdd,
"image/svg+xml" => Self::Svg,
mime => ImageFormat::from_mime_type(mime).map_or(Self::Unknown, Self::Raster),
}
}
pub fn from_extension(extension: &str) -> Self {
match extension.trim_start_matches('.').to_ascii_lowercase().as_str() {
FILE_EXTENSION => Self::GraphiteLegacy,
GDD_FILE_EXTENSION => Self::Gdd,
"svg" => Self::Svg,
extension => ImageFormat::from_extension(extension).map_or(Self::Unknown, Self::Raster),
}
}
pub fn from_path(path: impl AsRef<Path>) -> Self {
path.as_ref().extension().and_then(OsStr::to_str).map_or(Self::Unknown, Self::from_extension)
}
pub fn from_content(data: &[u8]) -> Self {
if ArchiveFormat::detect(data).is_some() {
return Self::Gdd;
}
if let Ok(format) = image::guess_format(data) {
return Self::Raster(format);
}
// Only the head is read as text, where a character split by the cut is the one invalid sequence tolerated
let head = &data[..data.len().min(SNIFFED_TEXT_LENGTH)];
let text = match std::str::from_utf8(head) {
Ok(text) => text,
Err(error) if error.error_len().is_none() => std::str::from_utf8(&head[..error.valid_up_to()]).unwrap_or_default(),
Err(_) => return Self::Unknown,
};
let text = text.trim_start_matches('\u{feff}').trim_start();
if text.starts_with('{') {
Self::GraphiteLegacy
} else if text.starts_with('<') && text.contains("<svg") {
Self::Svg
} else {
Self::Unknown
}
}
pub fn mime(self) -> Option<&'static str> {
Some(match self {
Self::GraphiteLegacy => "application/graphite+json",
Self::Gdd => "application/vnd.graphite.document",
Self::Svg => "image/svg+xml",
Self::Raster(format) => format.to_mime_type(),
Self::Unknown => return None,
})
}
pub fn extensions(self) -> &'static [&'static str] {
match self {
Self::GraphiteLegacy => &[FILE_EXTENSION],
Self::Gdd => &[GDD_FILE_EXTENSION],
Self::Svg => &["svg"],
Self::Raster(format) => format.extensions_str(),
Self::Unknown => &[],
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TypeFilter {
pub name: String,
pub types: Vec<DataType>,
}
impl TypeFilter {
pub fn documents() -> Self {
Self {
name: "Graphite Document".into(),
types: vec![DataType::Gdd, DataType::GraphiteLegacy],
}
}
pub fn raster() -> Self {
Self {
name: "Image".into(),
types: ImageFormat::all().filter(ImageFormat::reading_enabled).map(DataType::Raster).collect(),
}
}
pub fn image() -> Self {
let mut images = Self::raster();
images.types.push(DataType::Svg);
images
}
}
impl From<TypeFilter> for FileFilter {
fn from(filter: TypeFilter) -> Self {
Self {
name: filter.name,
extensions: filter.types.iter().flat_map(|data_type| data_type.extensions()).map(|extension| extension.to_string()).collect(),
mime_types: filter.types.iter().filter_map(|data_type| data_type.mime()).map(str::to_string).collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use graphene_std::Color;
use graphene_std::raster::Image;
#[test]
fn data_type_from_content() {
assert_eq!(DataType::from_content(&Image::new(1, 1, Color::WHITE).to_png()), DataType::Raster(ImageFormat::Png));
assert_eq!(DataType::from_content(b"PK\x03\x04"), DataType::Gdd);
assert_eq!(DataType::from_content(b"{\"network_interface\":{}}"), DataType::GraphiteLegacy);
assert_eq!(
DataType::from_content("\u{feff}<?xml version=\"1.0\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\"/>".as_bytes()),
DataType::Svg
);
assert_eq!(DataType::from_content(b"just text"), DataType::Unknown);
assert_eq!(DataType::from_content(&[0xff, 0xfe, 0x00]), DataType::Unknown);
}
#[test]
fn data_type_from_content_reads_only_the_head() {
let late_svg = format!("<!--{}--><svg/>", " ".repeat(SNIFFED_TEXT_LENGTH));
assert_eq!(DataType::from_content(late_svg.as_bytes()), DataType::Unknown);
// The three-byte euro sign straddles the cut, which must not hide the text before it
let straddling = format!("<svg>{}", " ".repeat(SNIFFED_TEXT_LENGTH - 6));
assert_eq!(DataType::from_content(straddling.as_bytes()), DataType::Svg);
}
#[test]
fn data_type_detect() {
let detect = |mime_type: &str, path: &str| DataType::detect(&[], mime_type, Some(Path::new(path)));
assert_eq!(detect("", "photo.JPEG"), DataType::Raster(ImageFormat::Jpeg));
assert_eq!(detect("image/svg+xml", ""), DataType::Svg);
assert_eq!(detect("application/graphite+json", ""), DataType::GraphiteLegacy);
assert_eq!(detect("image/png", "document.gdd"), DataType::Raster(ImageFormat::Png));
assert_eq!(detect("text/csv", "table.csv"), DataType::Unknown);
assert_eq!(DataType::detect(&[], "", None), DataType::Unknown);
let png = Image::new(1, 1, Color::WHITE).to_png();
assert_eq!(DataType::detect(&png, "image/svg+xml", Some(Path::new("drawing.svg"))), DataType::Raster(ImageFormat::Png));
}
#[test]
fn type_filter_to_file_filter() {
let filter = FileFilter::from(TypeFilter::image());
assert!(filter.extensions.iter().any(|extension| extension == "jpeg") && filter.extensions.iter().any(|extension| extension == "png"));
assert!(filter.extensions.last().is_some_and(|extension| extension == "svg"));
assert!(filter.mime_types.contains(&"image/jpeg".to_string()) && filter.mime_types.contains(&"image/svg+xml".to_string()));
}
}

View File

@@ -5,17 +5,17 @@ pub mod document;
pub mod document_migration;
pub mod document_storage_io;
pub mod fonts;
pub mod ingest;
pub mod persistent_state;
pub mod resource_upload;
pub mod utility_types;
#[doc(inline)]
pub use fonts::{FontsMessage, FontsMessageContext, FontsMessageHandler};
#[doc(inline)]
pub use ingest::{IngestMessage, IngestMessageContext, IngestMessageHandler};
#[doc(inline)]
pub use persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageHandler};
#[doc(inline)]
pub use portfolio_message::{PortfolioMessage, PortfolioMessageDiscriminant};
#[doc(inline)]
pub use portfolio_message_handler::{PortfolioMessageContext, PortfolioMessageHandler};
#[doc(inline)]
pub use resource_upload::{ResourceUploadMessage, ResourceUploadMessageContext, ResourceUploadMessageHandler};

View File

@@ -1,6 +1,5 @@
use super::document::utility_types::document_metadata::LayerNodeIdentifier;
use super::persistent_state::PersistentStateMessage;
use super::resource_upload::ResourceUploadMessage;
use super::utility_types::{DockingSplitDirection, PanelGroupId, PanelType};
use crate::messages::frontend::utility_types::{ExportBounds, FileType, PersistedState};
use crate::messages::prelude::*;
@@ -16,9 +15,9 @@ pub enum PortfolioMessage {
#[child]
Fonts(FontsMessage),
#[child]
PersistentState(PersistentStateMessage),
Ingest(IngestMessage),
#[child]
ResourceUpload(ResourceUploadMessage),
PersistentState(PersistentStateMessage),
// Messages
Init,
@@ -89,16 +88,6 @@ pub enum PortfolioMessage {
name: String,
},
NextDocument,
Open,
Import,
OpenFile {
path: PathBuf,
content: Vec<u8>,
},
ImportFile {
path: PathBuf,
content: Vec<u8>,
},
OpenDocumentFile {
document_name: Option<String>,
document_path: Option<PathBuf>,
@@ -139,16 +128,6 @@ pub enum PortfolioMessage {
document_is_saved: bool,
document_serialized_content: String,
},
OpenSvg {
name: Option<String>,
svg: String,
},
InsertSvg {
name: Option<String>,
svg: String,
mouse: Option<(f64, f64)>,
parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>,
},
CenterLayers {
layers: Vec<LayerNodeIdentifier>,
},

View File

@@ -15,8 +15,6 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions;
use crate::messages::portfolio::document::utility_types::network_interface::OutputConnector;
use crate::messages::portfolio::document_migration::*;
use crate::messages::portfolio::document_storage_io::{build_or_open_working_copy, compare_storage_against_runtime, open_gdd_document};
use crate::messages::portfolio::resource_upload::utility_types::{UploadTarget, image_file_filter};
use crate::messages::portfolio::utility_types::FileContent;
use crate::messages::preferences::SelectionMode;
use crate::messages::prelude::*;
use crate::messages::tool::utility_types::{HintData, ToolType};
@@ -26,7 +24,7 @@ use glam::{DAffine2, DVec2};
use graph_craft::application_io::resource::{DataSource, ResourceHash};
use graph_craft::document::NodeId;
use graphene_std::renderer::Quad;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::sync::Arc;
use std::vec;
@@ -60,7 +58,7 @@ pub struct PortfolioMessageHandler {
pub(crate) active_document_id: Option<DocumentId>,
persistent_state: PersistentStateMessageHandler,
pub fonts: FontsMessageHandler,
resource_upload: ResourceUploadMessageHandler,
ingest: IngestMessageHandler,
pub executor: NodeGraphExecutor,
pub selection_mode: SelectionMode,
pub reset_node_definitions_on_open: bool,
@@ -116,11 +114,11 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
let context = FontsMessageContext { resource_storage };
self.fonts.process_message(message, responses, context);
}
PortfolioMessage::ResourceUpload(message) => {
let context = ResourceUploadMessageContext {
PortfolioMessage::Ingest(message) => {
let context = IngestMessageContext {
document_open: self.active_document().is_some(),
};
self.resource_upload.process_message(message, responses, context);
self.ingest.process_message(message, responses, context);
}
// Messages
@@ -510,6 +508,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
filters: vec![FileFilter {
name: "Graphite Document".into(),
extensions: vec![FILE_EXTENSION.into()],
mime_types: Vec::new(),
}],
content: serde_bytes::ByteBuf::from(content),
});
@@ -521,6 +520,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
filters: vec![FileFilter {
name: "Zip Archive".into(),
extensions: vec!["zip".into()],
mime_types: Vec::new(),
}],
content: serde_bytes::ByteBuf::from(zip_bytes),
}),
@@ -664,107 +664,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
responses.add(PortfolioMessage::SelectDocument { document_id: next_id });
}
}
PortfolioMessage::Open => {
// This portfolio message wraps the frontend message so it can be listed as an action, which isn't possible for frontend messages
responses.add(FrontendMessage::TriggerOpen {
filters: vec![
FileFilter {
name: "Graphite Document".into(),
extensions: vec![FILE_EXTENSION.into(), GDD_FILE_EXTENSION.into()],
},
image_file_filter(),
],
});
}
PortfolioMessage::Import => {
// This portfolio message wraps the frontend message so it can be listed as an action, which isn't possible for frontend messages
// TODO: Also offer the Graphite document filter once importing Graphite documents as nodes is supported
responses.add(FrontendMessage::TriggerImport { filters: vec![image_file_filter()] });
}
PortfolioMessage::OpenFile { path, content } => {
let name = path.file_stem().map(|n| n.to_string_lossy().to_string());
match Self::read_file(&path, content) {
FileContent::Document(content) => {
let document_path = if path.is_absolute() { Some(path) } else { None };
responses.add(PortfolioMessage::OpenDocumentFile {
document_name: name,
document_path,
document_serialized_content: content,
});
}
FileContent::GddDocument(content) => {
let document_path = if path.is_absolute() { Some(path) } else { None };
responses.add(PortfolioMessage::OpenGddDocument {
document_name: name,
document_path,
content,
});
}
FileContent::Svg(svg) => {
responses.add(PortfolioMessage::OpenSvg { name, svg });
}
FileContent::Image(data) => {
responses.add(ResourceUploadMessage::Upload {
name,
data: data.into(),
target: UploadTarget::Document,
});
}
FileContent::Unsupported => {
// TODO: Show a more thoughtfully designed error message to the user
responses.add(DialogMessage::DisplayDialogError {
title: "Unsupported format".into(),
description: "This file cannot be opened because it is not a supported image file type.".into(),
})
}
}
}
PortfolioMessage::ImportFile { path, content } => {
let name = path.file_stem().map(|n| n.to_string_lossy().to_string());
match Self::read_file(&path, content) {
FileContent::Document(content) => {
// TODO: Consider importing a document as a node into the current document
// For now treat importing a document as opening it
responses.add(PortfolioMessage::OpenDocumentFile {
document_name: name,
document_path: Some(path),
document_serialized_content: content,
});
}
FileContent::GddDocument(content) => {
responses.add(PortfolioMessage::OpenGddDocument {
document_name: name,
document_path: Some(path),
content,
});
}
FileContent::Svg(svg) => {
responses.add(PortfolioMessage::InsertSvg {
name,
svg,
mouse: None,
parent_and_insert_index: None,
});
}
FileContent::Image(data) => {
responses.add(ResourceUploadMessage::Upload {
name,
data: data.into(),
target: UploadTarget::Layer {
mouse: None,
parent_and_insert_index: None,
},
});
}
FileContent::Unsupported => {
// TODO: Show a more thoughtfully designed error message to the user
responses.add(DialogMessage::DisplayDialogError {
title: "Unsupported format".into(),
description: "This file cannot be imported because it is not a supported image file type.".into(),
})
}
}
}
PortfolioMessage::OpenDocumentFile {
document_name,
document_path,
@@ -997,61 +896,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
self.tick_autosave_load_progress(responses, false);
}
}
PortfolioMessage::OpenSvg { name, svg } => {
responses.add(PortfolioMessage::NewDocumentWithName {
name: name.clone().unwrap_or_default(),
});
// Parse the SVG to extract its declared canvas origin and dimensions from the viewBox attribute.
// This preserves the full canvas rather than measuring only the tighter rendered content bounding box.
let artboard_canvas = usvg::roxmltree::Document::parse(&svg)
.ok()
.and_then(|doc| {
let vb = doc.root_element().attribute("viewBox")?;
let nums: Vec<f64> = vb
.split(|c: char| c.is_ascii_whitespace() || c == ',')
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse().ok())
.collect();
if nums.len() >= 4 {
Some((
glam::IVec2::new(nums[0].round() as i32, nums[1].round() as i32),
glam::IVec2::new(nums[2].round() as i32, nums[3].round() as i32),
))
} else {
None
}
})
.or_else(|| {
// Fall back to the viewport size when there is no viewBox attribute
usvg::Tree::from_str(&svg, &usvg::Options::default()).ok().map(|tree| {
let size = tree.size();
(glam::IVec2::ZERO, glam::IVec2::new(size.width().round() as i32, size.height().round() as i32))
})
});
responses.add(DocumentMessage::InsertSvg {
name,
svg,
mouse: None,
parent_and_insert_index: None,
place_at_origin: true,
});
// Wait for the document to be rendered so the click targets can be calculated in order to determine the artboard size that will encompass the pasted SVG
responses.add(DeferMessage::AfterGraphRun {
messages: vec![
DocumentMessage::WrapContentInArtboard {
place_artboard_at_origin: true,
artboard_canvas,
}
.into(),
],
});
responses.add(DeferMessage::AfterNavigationReady {
messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()],
});
}
PortfolioMessage::CenterLayers { layers } => {
if let Some(document) = self.active_document_mut() {
let viewport_bounds_quad_pixels = Quad::from_box([DVec2::ZERO, viewport.size().into_dvec2()]); // In viewport pixel coordinates
@@ -1157,24 +1001,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
responses.add(NodeGraphMessage::RunDocumentGraph);
}
}
PortfolioMessage::InsertSvg {
name,
svg,
mouse,
parent_and_insert_index,
} => {
if self.document_ids.is_empty() {
responses.add(PortfolioMessage::OpenSvg { name, svg });
} else {
responses.add(DocumentMessage::InsertSvg {
name,
svg,
mouse,
parent_and_insert_index,
place_at_origin: false,
});
}
}
PortfolioMessage::PrevDocument => {
if let Some(active_document_id) = self.active_document_id {
let len = self.document_ids.len();
@@ -1235,12 +1061,8 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
ShortcutLabel::new(action_shortcut!(DialogMessageDiscriminant::RequestNewDocumentDialog)).widget_instance(),
],
vec![
TextButton::new("Open Document")
.icon("Folder")
.flush(true)
.on_commit(|_| PortfolioMessage::Open.into())
.widget_instance(),
ShortcutLabel::new(action_shortcut!(PortfolioMessageDiscriminant::Open)).widget_instance(),
TextButton::new("Open Document").icon("Folder").flush(true).on_commit(|_| IngestMessage::Open.into()).widget_instance(),
ShortcutLabel::new(action_shortcut!(IngestMessageDiscriminant::Open)).widget_instance(),
],
vec![
TextButton::new("Open Demo Artwork")
@@ -1652,9 +1474,9 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
fn actions(&self) -> ActionList {
let mut common = actions!(PortfolioMessageDiscriminant;
Open,
ToggleFocusDocument,
);
common.extend(actions!(IngestMessageDiscriminant; Open));
// Extend with actions that require an active document
if let Some(document) = self.active_document() {
@@ -1666,8 +1488,8 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
ToggleRulers,
NextDocument,
PrevDocument,
Import,
));
common.extend(actions!(IngestMessageDiscriminant; Import));
}
// Extend with actions that are disabled when focusing the document
@@ -1782,22 +1604,6 @@ impl PortfolioMessageHandler {
}
}
fn read_file(path: &Path, content: Vec<u8>) -> FileContent {
let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or_default().to_lowercase();
match extension.as_str() {
FILE_EXTENSION => match String::from_utf8(content) {
Ok(content) => FileContent::Document(content),
Err(_) => FileContent::Unsupported,
},
GDD_FILE_EXTENSION => FileContent::GddDocument(content),
"svg" => match String::from_utf8(content) {
Ok(content) => FileContent::Svg(content),
Err(_) => FileContent::Unsupported,
},
_ => FileContent::Image(content),
}
}
fn load_document(
&mut self,
mut new_document: DocumentMessageHandler,

View File

@@ -1,9 +0,0 @@
mod resource_upload_message;
mod resource_upload_message_handler;
pub mod utility_types;
#[doc(inline)]
pub use resource_upload_message::{ResourceUploadMessage, ResourceUploadMessageDiscriminant};
#[doc(inline)]
pub use resource_upload_message_handler::{ResourceUploadMessageContext, ResourceUploadMessageHandler};

View File

@@ -1,14 +0,0 @@
use super::utility_types::UploadTarget;
use crate::messages::prelude::*;
use std::sync::Arc;
#[impl_message(Message, PortfolioMessage, ResourceUpload)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum ResourceUploadMessage {
/// Opens a file dialog filtered to what the target accepts, whose pick arrives as [`ResourceUploadMessage::ReceiveUpload`].
RequestUpload { target: UploadTarget },
/// The file picked for the pending [`ResourceUploadMessage::RequestUpload`].
ReceiveUpload { name: Option<String>, data: Arc<[u8]> },
/// Stores a file as an embedded resource of the active document and hands it to its target.
Upload { name: Option<String>, data: Arc<[u8]>, target: UploadTarget },
}

View File

@@ -1,216 +0,0 @@
use super::utility_types::{ImageResource, UNSUPPORTED_IMAGE_FILE, UploadTarget, decoded_image_size};
use crate::messages::prelude::*;
use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue;
#[derive(ExtractField)]
pub struct ResourceUploadMessageContext {
/// Whether a document is open to receive an image layer, since otherwise the image opens its own
pub document_open: bool,
}
/// Runs the file dialog for resource uploads and stores every upload in the active document before handing it to its target.
#[derive(Debug, Default, ExtractField)]
pub struct ResourceUploadMessageHandler {
/// Where the file from the open dialog goes once it is picked
pending_target: Option<UploadTarget>,
}
#[message_handler_data]
impl MessageHandler<ResourceUploadMessage, ResourceUploadMessageContext> for ResourceUploadMessageHandler {
fn process_message(&mut self, message: ResourceUploadMessage, responses: &mut VecDeque<Message>, context: ResourceUploadMessageContext) {
match message {
ResourceUploadMessage::RequestUpload { target } => {
self.pending_target = Some(target);
responses.add(FrontendMessage::TriggerUploadResource { filters: target.filters() });
}
ResourceUploadMessage::ReceiveUpload { name, data } => {
let Some(target) = self.pending_target.take() else {
log::warn!("A file was picked without a pending upload request");
return;
};
responses.add(ResourceUploadMessage::Upload { name, data, target });
}
ResourceUploadMessage::Upload { name, data, target } => {
let reject = |responses: &mut VecDeque<Message>, description: &str| {
responses.add(DialogMessage::DisplayDialogError {
title: "Unsupported image format".into(),
description: description.into(),
});
};
match target {
UploadTarget::NodeInput {
document_id,
node_id,
input_index,
kind,
} => {
if let Some(description) = kind.rejection(&data) {
return reject(responses, description);
}
// The file goes to the document that asked for it, which may no longer be active or open once the dialog closes
let resource_id = ResourceId::new();
let messages = [
DocumentMessage::AddTransaction,
DocumentMessage::Resource(ResourceMessage::StoreEmbedded { resource_id, data }),
DocumentMessage::NodeGraph(NodeGraphMessage::SetInputValue {
node_id,
input_index,
value: Box::new(TaggedValue::Resource(resource_id)),
}),
];
for message in messages {
responses.add(PortfolioMessage::DocumentPassMessage { document_id, message });
}
}
UploadTarget::Layer { .. } | UploadTarget::Document => {
let Some((width, height)) = decoded_image_size(&data) else {
return reject(responses, UNSUPPORTED_IMAGE_FILE);
};
// A layer needs a document to land in, so without one the image opens its own, wrapped in an artboard once rendered
let (mouse, parent_and_insert_index, place_at_origin) = match target {
UploadTarget::Layer { mouse, parent_and_insert_index } if context.document_open => (mouse, parent_and_insert_index, false),
_ => {
// An empty name becomes the next available "Untitled Document"
responses.add(PortfolioMessage::NewDocumentWithName {
name: name.clone().unwrap_or_default(),
});
(None, None, true)
}
};
let resource_id = ResourceId::new();
responses.add(ResourceMessage::StoreEmbedded { resource_id, data });
responses.add(DocumentMessage::InsertImage {
name,
image: ImageResource { resource_id, width, height },
mouse,
parent_and_insert_index,
place_at_origin,
});
if place_at_origin {
responses.add(DeferMessage::AfterGraphRun {
messages: vec![
DocumentMessage::WrapContentInArtboard {
place_artboard_at_origin: true,
artboard_canvas: None,
}
.into(),
],
});
responses.add(DeferMessage::AfterNavigationReady {
messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()],
});
}
}
}
}
}
}
fn actions(&self) -> ActionList {
actions!(ResourceUploadMessageDiscriminant;)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::messages::portfolio::resource_upload::utility_types::ResourceFileKind;
use graph_craft::document::NodeId;
use graphene_std::Color;
use graphene_std::raster::Image;
fn upload(data: &[u8], target: UploadTarget, document_open: bool) -> VecDeque<Message> {
let mut responses = VecDeque::new();
let message = ResourceUploadMessage::Upload {
name: None,
data: data.into(),
target,
};
ResourceUploadMessageHandler::default().process_message(message, &mut responses, ResourceUploadMessageContext { document_open });
responses
}
fn stores_a_resource(message: &Message) -> bool {
matches!(
message,
Message::Portfolio(PortfolioMessage::Document(DocumentMessage::Resource(ResourceMessage::StoreEmbedded { .. })))
)
}
#[test]
fn a_file_that_is_not_an_image_is_rejected_before_it_is_stored() {
let target = UploadTarget::NodeInput {
document_id: DocumentId(3),
node_id: NodeId(7),
input_index: 1,
kind: ResourceFileKind::RasterImage,
};
let responses = upload(b"not an image", target, true);
assert!(!responses.iter().any(stores_a_resource), "a rejected file should not become a resource");
assert!(
responses.iter().any(|message| matches!(message, Message::Dialog(DialogMessage::DisplayDialogError { .. }))),
"the user should be told why"
);
}
#[test]
fn a_picked_file_goes_to_the_target_of_the_pending_request() {
let target = UploadTarget::NodeInput {
document_id: DocumentId(3),
node_id: NodeId(7),
input_index: 1,
kind: ResourceFileKind::Any,
};
let mut handler = ResourceUploadMessageHandler::default();
let context = || ResourceUploadMessageContext { document_open: true };
let mut responses = VecDeque::new();
handler.process_message(ResourceUploadMessage::RequestUpload { target }, &mut responses, context());
assert!(responses.contains(&FrontendMessage::TriggerUploadResource { filters: Vec::new() }.into()), "the dialog should open");
let mut responses = VecDeque::new();
let picked = ResourceUploadMessage::ReceiveUpload {
name: Some("file.bin".into()),
data: b"bytes".as_slice().into(),
};
handler.process_message(picked, &mut responses, context());
let expected = ResourceUploadMessage::Upload {
name: Some("file.bin".into()),
data: b"bytes".as_slice().into(),
target,
};
assert!(responses.contains(&expected.into()), "the picked file should be uploaded to the requested target");
}
#[test]
fn an_image_layer_opens_a_document_when_none_is_open() {
let png = Image::new(1, 1, Color::WHITE).to_png();
let target = UploadTarget::Layer {
mouse: None,
parent_and_insert_index: None,
};
let opens_a_document = |message: &Message| matches!(message, Message::Portfolio(PortfolioMessage::NewDocumentWithName { .. }));
let inserts_at_origin = |message: &Message| matches!(message, Message::Portfolio(PortfolioMessage::Document(DocumentMessage::InsertImage { place_at_origin, .. })) if *place_at_origin);
let responses = upload(&png, target, true);
assert!(responses.iter().any(stores_a_resource), "an image should be stored as a resource");
assert!(
!responses.iter().any(opens_a_document) && !responses.iter().any(inserts_at_origin),
"an open document should receive the layer"
);
let responses = upload(&png, target, false);
assert!(
responses.iter().position(opens_a_document) < responses.iter().position(stores_a_resource),
"the document should exist before the resource is stored in it"
);
assert!(responses.iter().any(inserts_at_origin), "the image should sit at the origin of its new document");
}
}

View File

@@ -1,96 +0,0 @@
use crate::messages::frontend::utility_types::FileFilter;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::prelude::DocumentId;
use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::NodeId;
/// The raster image formats the editor decodes, by file extension.
pub const RASTER_IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "bmp", "gif"];
pub const VECTOR_IMAGE_EXTENSIONS: &[&str] = &["svg"];
/// The dialog error shown for a file that does not decode as a raster image.
pub const UNSUPPORTED_IMAGE_FILE: &str = "The loaded file is not a supported bitmap image format.";
/// The file dialog filter for the raster image formats the editor decodes.
pub fn raster_image_file_filter() -> FileFilter {
FileFilter {
name: "Image".into(),
extensions: RASTER_IMAGE_EXTENSIONS.iter().map(|extension| extension.to_string()).collect(),
}
}
/// The file dialog filter for every image the editor opens or imports, vector as well as raster.
pub fn image_file_filter() -> FileFilter {
FileFilter {
name: "Image".into(),
extensions: RASTER_IMAGE_EXTENSIONS.iter().chain(VECTOR_IMAGE_EXTENSIONS.iter()).map(|ext| ext.to_string()).collect(),
}
}
/// The pixel size of a file that fully decodes as a raster image.
pub fn decoded_image_size(data: &[u8]) -> Option<(u32, u32)> {
image::load_from_memory(data).ok().map(|image| (image.width(), image.height()))
}
/// What a file must decode as before a node input accepts it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum ResourceFileKind {
#[default]
Any,
RasterImage,
}
impl ResourceFileKind {
/// The file dialog filters offered for this kind.
pub fn filters(self) -> Vec<FileFilter> {
match self {
Self::Any => Vec::new(),
Self::RasterImage => vec![raster_image_file_filter()],
}
}
/// The dialog error to show when the file's contents do not decode as this kind.
pub fn rejection(self, data: &[u8]) -> Option<&'static str> {
match self {
Self::Any => None,
Self::RasterImage => decoded_image_size(data).is_none().then_some(UNSUPPORTED_IMAGE_FILE),
}
}
}
/// Where an uploaded file goes once it is stored as a resource.
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum UploadTarget {
/// A node input in the given document that accepts the given kind of file.
NodeInput {
document_id: DocumentId,
node_id: NodeId,
input_index: usize,
kind: ResourceFileKind,
},
/// A new image layer in the active document, centered on the mouse or else the viewport.
Layer {
mouse: Option<(f64, f64)>,
parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>,
},
/// A new document sized to the image.
Document,
}
impl UploadTarget {
/// The file dialog filters for what this target accepts.
pub fn filters(self) -> Vec<FileFilter> {
match self {
Self::NodeInput { kind, .. } => kind.filters(),
Self::Layer { .. } | Self::Document => vec![raster_image_file_filter()],
}
}
}
/// An uploaded image by its stored resource and pixel size.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ImageResource {
pub resource_id: ResourceId,
pub width: u32,
pub height: u32,
}

View File

@@ -784,16 +784,3 @@ impl PanelLayoutSubdivision {
}
}
}
pub enum FileContent {
/// A legacy `.graphite` document (serialized runtime JSON).
Document(String),
/// A `.gdd` document container (archive bytes).
GddDocument(Vec<u8>),
/// Any other file, expected to be a bitmap image.
Image(Vec<u8>),
/// An SVG file string.
Svg(String),
/// Any other unsupported/unrecognized file type.
Unsupported,
}

View File

@@ -32,8 +32,8 @@ pub use crate::messages::portfolio::document::properties_panel::{PropertiesPanel
pub use crate::messages::portfolio::document::resource::{ResourceMessage, ResourceMessageContext, ResourceMessageDiscriminant, ResourceMessageHandler};
pub use crate::messages::portfolio::document::{DocumentMessage, DocumentMessageContext, DocumentMessageDiscriminant, DocumentMessageHandler};
pub use crate::messages::portfolio::fonts::{FontsMessage, FontsMessageContext, FontsMessageDiscriminant, FontsMessageHandler};
pub use crate::messages::portfolio::ingest::{IngestMessage, IngestMessageContext, IngestMessageDiscriminant, IngestMessageHandler};
pub use crate::messages::portfolio::persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageDiscriminant, PersistentStateMessageHandler};
pub use crate::messages::portfolio::resource_upload::{ResourceUploadMessage, ResourceUploadMessageContext, ResourceUploadMessageDiscriminant, ResourceUploadMessageHandler};
pub use crate::messages::portfolio::{PortfolioMessage, PortfolioMessageContext, PortfolioMessageDiscriminant, PortfolioMessageHandler};
pub use crate::messages::preferences::{PreferencesMessage, PreferencesMessageDiscriminant, PreferencesMessageHandler};
pub use crate::messages::resource_storage::{ResourceStorageMessage, ResourceStorageMessageContext, ResourceStorageMessageDiscriminant, ResourceStorageMessageHandler};

View File

@@ -5,7 +5,6 @@ use crate::messages::portfolio::document::utility_types::network_interface::{Flo
use crate::messages::prelude::*;
use glam::{DAffine2, DVec2};
use graph_craft::ProtoNodeIdentifier;
use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNode, NodeId, NodeInput};
use graphene_std::Color;
@@ -16,6 +15,7 @@ use graphene_std::vector::style::{FillChoice, PaintOrder, StrokeAlign, StrokeCap
use graphene_std::vector::{Gradient, GradientForm, GradientRamp, GradientSettings, PointId, SegmentId, VectorModificationType};
use graphene_std::{NodeParameter, ParameterRef};
use std::collections::VecDeque;
use std::sync::Arc;
/// Returns the ID of the first Spline node in the horizontal flow which is not followed by a `Path` node, or `None` if none exists.
pub fn find_spline(document: &DocumentMessageHandler, layer: LayerNodeIdentifier) -> Option<NodeId> {
@@ -206,15 +206,10 @@ pub fn merge_points(document: &DocumentMessageHandler, layer: LayerNodeIdentifie
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}
/// Create a new bitmap layer showing a stored image resource.
pub fn new_image_layer(resource_id: ResourceId, id: NodeId, parent: LayerNodeIdentifier, responses: &mut VecDeque<Message>) -> LayerNodeIdentifier {
/// Create a new bitmap layer.
pub fn new_image_layer(data: Arc<[u8]>, id: NodeId, parent: LayerNodeIdentifier, responses: &mut VecDeque<Message>) -> LayerNodeIdentifier {
let insert_index = 0;
responses.add(GraphOperationMessage::NewBitmapLayer {
id,
resource_id,
parent,
insert_index,
});
responses.add(GraphOperationMessage::NewBitmapLayer { id, data, parent, insert_index });
LayerNodeIdentifier::new_unchecked(id)
}

View File

@@ -2,7 +2,7 @@ use crate::application::Editor;
use crate::messages::input_mapper::utility_types::keyboard::ModifierKeys;
use crate::messages::input_mapper::utility_types::pointer::{EditorPointerState, MouseKeys, ViewportPosition};
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
use crate::messages::portfolio::resource_upload::utility_types::UploadTarget;
use crate::messages::portfolio::ingest::utility_types::IngestAction;
use crate::messages::prelude::*;
use crate::messages::tool::tool_messages::tool_prelude::Key;
use crate::messages::tool::utility_types::ToolType;
@@ -243,10 +243,11 @@ impl EditorTestUtils {
}
pub async fn create_raster_image(&mut self, image: graphene_std::raster::Image<Color>, mouse: Option<(f64, f64)>) {
self.handle_message(ResourceUploadMessage::Upload {
name: None,
data: image.to_png().into(),
target: UploadTarget::Layer { mouse, parent_and_insert_index: None },
self.handle_message(IngestMessage::Ingest {
data: image.to_png(),
action: mouse.map_or(IngestAction::Paste, |mouse| IngestAction::DropOnCanvas { mouse }),
mime_type: String::new(),
path: None,
})
.await;
}