Implement download/copy ImageFrame layer output (#1194)
* Implement download/copy ImageFrame layer output * Add note about black transparency when copying * Introspect node graph output type to conditionally disable the download button --------- Co-authored-by: Dennis Kobert <dennis@kobert.dev>
This commit is contained in:
parent
1aaf2a521b
commit
ebf67eaa82
|
|
@ -449,7 +449,7 @@ impl Layer {
|
||||||
|
|
||||||
/// Get a mutable reference to the NodeNetwork
|
/// Get a mutable reference to the NodeNetwork
|
||||||
/// This operation will fail if the [Layer type](Layer::data) is not `LayerDataType::Layer`.
|
/// This operation will fail if the [Layer type](Layer::data) is not `LayerDataType::Layer`.
|
||||||
pub fn as_node_graph_mut(&mut self) -> Result<&mut graph_craft::document::NodeNetwork, DocumentError> {
|
pub fn as_layer_network_mut(&mut self) -> Result<&mut graph_craft::document::NodeNetwork, DocumentError> {
|
||||||
match &mut self.data {
|
match &mut self.data {
|
||||||
LayerDataType::Layer(layer) => Ok(&mut layer.network),
|
LayerDataType::Layer(layer) => Ok(&mut layer.network),
|
||||||
_ => Err(DocumentError::NotNodeGraph),
|
_ => Err(DocumentError::NotNodeGraph),
|
||||||
|
|
@ -458,14 +458,14 @@ impl Layer {
|
||||||
|
|
||||||
/// Get a reference to the NodeNetwork
|
/// Get a reference to the NodeNetwork
|
||||||
/// This operation will fail if the [Layer type](Layer::data) is not `LayerDataType::Layer`.
|
/// This operation will fail if the [Layer type](Layer::data) is not `LayerDataType::Layer`.
|
||||||
pub fn as_node_graph(&self) -> Result<&graph_craft::document::NodeNetwork, DocumentError> {
|
pub fn as_layer_network(&self) -> Result<&graph_craft::document::NodeNetwork, DocumentError> {
|
||||||
match &self.data {
|
match &self.data {
|
||||||
LayerDataType::Layer(layer) => Ok(&layer.network),
|
LayerDataType::Layer(layer) => Ok(&layer.network),
|
||||||
_ => Err(DocumentError::NotNodeGraph),
|
_ => Err(DocumentError::NotNodeGraph),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_graph_frame(&self) -> Result<&LayerLayer, DocumentError> {
|
pub fn as_layer(&self) -> Result<&LayerLayer, DocumentError> {
|
||||||
match &self.data {
|
match &self.data {
|
||||||
LayerDataType::Layer(layer) => Ok(layer),
|
LayerDataType::Layer(layer) => Ok(layer),
|
||||||
_ => Err(DocumentError::NotNodeGraph),
|
_ => Err(DocumentError::NotNodeGraph),
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,23 @@ pub enum FrontendMessage {
|
||||||
#[serde(rename = "commitDate")]
|
#[serde(rename = "commitDate")]
|
||||||
commit_date: String,
|
commit_date: String,
|
||||||
},
|
},
|
||||||
TriggerFileDownload {
|
TriggerCopyToClipboardBlobUrl {
|
||||||
|
#[serde(rename = "blobUrl")]
|
||||||
|
blob_url: String,
|
||||||
|
},
|
||||||
|
TriggerDownloadBlobUrl {
|
||||||
|
#[serde(rename = "layerName")]
|
||||||
|
layer_name: String,
|
||||||
|
#[serde(rename = "blobUrl")]
|
||||||
|
blob_url: String,
|
||||||
|
},
|
||||||
|
TriggerDownloadRaster {
|
||||||
|
svg: String,
|
||||||
|
name: String,
|
||||||
|
mime: String,
|
||||||
|
size: (f64, f64),
|
||||||
|
},
|
||||||
|
TriggerDownloadTextFile {
|
||||||
document: String,
|
document: String,
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
|
|
@ -107,12 +123,6 @@ pub enum FrontendMessage {
|
||||||
TriggerLoadPreferences,
|
TriggerLoadPreferences,
|
||||||
TriggerOpenDocument,
|
TriggerOpenDocument,
|
||||||
TriggerPaste,
|
TriggerPaste,
|
||||||
TriggerRasterDownload {
|
|
||||||
svg: String,
|
|
||||||
name: String,
|
|
||||||
mime: String,
|
|
||||||
size: (f64, f64),
|
|
||||||
},
|
|
||||||
TriggerRasterizeRegionBelowLayer {
|
TriggerRasterizeRegionBelowLayer {
|
||||||
#[serde(rename = "documentId")]
|
#[serde(rename = "documentId")]
|
||||||
document_id: u64,
|
document_id: u64,
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ pub enum DocumentMessage {
|
||||||
BooleanOperation(BooleanOperationType),
|
BooleanOperation(BooleanOperationType),
|
||||||
ClearLayerTree,
|
ClearLayerTree,
|
||||||
CommitTransaction,
|
CommitTransaction,
|
||||||
|
CopyToClipboardLayerImageOutput {
|
||||||
|
layer_path: Vec<LayerId>,
|
||||||
|
},
|
||||||
CreateEmptyFolder {
|
CreateEmptyFolder {
|
||||||
container_path: Vec<LayerId>,
|
container_path: Vec<LayerId>,
|
||||||
},
|
},
|
||||||
|
|
@ -72,6 +75,9 @@ pub enum DocumentMessage {
|
||||||
DocumentHistoryBackward,
|
DocumentHistoryBackward,
|
||||||
DocumentHistoryForward,
|
DocumentHistoryForward,
|
||||||
DocumentStructureChanged,
|
DocumentStructureChanged,
|
||||||
|
DownloadLayerImageOutput {
|
||||||
|
layer_path: Vec<LayerId>,
|
||||||
|
},
|
||||||
DuplicateSelectedLayers,
|
DuplicateSelectedLayers,
|
||||||
ExportDocument {
|
ExportDocument {
|
||||||
file_name: String,
|
file_name: String,
|
||||||
|
|
|
||||||
|
|
@ -278,6 +278,15 @@ impl MessageHandler<DocumentMessage, (u64, &InputPreprocessorMessageHandler, &Pe
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
CommitTransaction => (),
|
CommitTransaction => (),
|
||||||
|
CopyToClipboardLayerImageOutput { layer_path } => {
|
||||||
|
let layer = self.document_legacy.layer(&layer_path).ok();
|
||||||
|
|
||||||
|
let blob_url = layer.and_then(|layer| layer.as_layer().ok()).and_then(|layer_layer| layer_layer.as_blob_url()).cloned();
|
||||||
|
|
||||||
|
if let Some(blob_url) = blob_url {
|
||||||
|
responses.add(FrontendMessage::TriggerCopyToClipboardBlobUrl { blob_url });
|
||||||
|
}
|
||||||
|
}
|
||||||
CreateEmptyFolder { mut container_path } => {
|
CreateEmptyFolder { mut container_path } => {
|
||||||
let id = generate_uuid();
|
let id = generate_uuid();
|
||||||
container_path.push(id);
|
container_path.push(id);
|
||||||
|
|
@ -326,6 +335,17 @@ impl MessageHandler<DocumentMessage, (u64, &InputPreprocessorMessageHandler, &Pe
|
||||||
let data_buffer: RawBuffer = self.serialize_root().as_slice().into();
|
let data_buffer: RawBuffer = self.serialize_root().as_slice().into();
|
||||||
responses.add(FrontendMessage::UpdateDocumentLayerTreeStructure { data_buffer })
|
responses.add(FrontendMessage::UpdateDocumentLayerTreeStructure { data_buffer })
|
||||||
}
|
}
|
||||||
|
DownloadLayerImageOutput { layer_path } => {
|
||||||
|
let layer = self.document_legacy.layer(&layer_path).ok();
|
||||||
|
|
||||||
|
let layer_name = layer.map(|layer| layer.name.clone().unwrap_or_else(|| "Untitled Layer".to_string()));
|
||||||
|
|
||||||
|
let blob_url = layer.and_then(|layer| layer.as_layer().ok()).and_then(|layer_layer| layer_layer.as_blob_url()).cloned();
|
||||||
|
|
||||||
|
if let (Some(layer_name), Some(blob_url)) = (layer_name, blob_url) {
|
||||||
|
responses.add(FrontendMessage::TriggerDownloadBlobUrl { layer_name, blob_url });
|
||||||
|
}
|
||||||
|
}
|
||||||
DuplicateSelectedLayers => {
|
DuplicateSelectedLayers => {
|
||||||
self.backup(responses);
|
self.backup(responses);
|
||||||
responses.add_front(SetSelectedLayers { replacement_selected_layers: vec![] });
|
responses.add_front(SetSelectedLayers { replacement_selected_layers: vec![] });
|
||||||
|
|
@ -353,6 +373,7 @@ impl MessageHandler<DocumentMessage, (u64, &InputPreprocessorMessageHandler, &Pe
|
||||||
let transform = (DAffine2::from_translation(bounds[0]) * DAffine2::from_scale(size)).inverse();
|
let transform = (DAffine2::from_translation(bounds[0]) * DAffine2::from_scale(size)).inverse();
|
||||||
|
|
||||||
let document = self.render_document(size, transform, persistent_data, DocumentRenderMode::Root);
|
let document = self.render_document(size, transform, persistent_data, DocumentRenderMode::Root);
|
||||||
|
|
||||||
self.restore_document_transform(old_transforms);
|
self.restore_document_transform(old_transforms);
|
||||||
|
|
||||||
let file_suffix = &format!(".{file_type:?}").to_lowercase();
|
let file_suffix = &format!(".{file_type:?}").to_lowercase();
|
||||||
|
|
@ -362,11 +383,11 @@ impl MessageHandler<DocumentMessage, (u64, &InputPreprocessorMessageHandler, &Pe
|
||||||
};
|
};
|
||||||
|
|
||||||
if file_type == FileType::Svg {
|
if file_type == FileType::Svg {
|
||||||
responses.add(FrontendMessage::TriggerFileDownload { document, name });
|
responses.add(FrontendMessage::TriggerDownloadTextFile { document, name });
|
||||||
} else {
|
} else {
|
||||||
let mime = file_type.to_mime().to_string();
|
let mime = file_type.to_mime().to_string();
|
||||||
let size = (size * scale_factor).into();
|
let size = (size * scale_factor).into();
|
||||||
responses.add(FrontendMessage::TriggerRasterDownload { svg: document, name, mime, size });
|
responses.add(FrontendMessage::TriggerDownloadRaster { svg: document, name, mime, size });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FlipSelectedLayers { flip_axis } => {
|
FlipSelectedLayers { flip_axis } => {
|
||||||
|
|
@ -702,7 +723,7 @@ impl MessageHandler<DocumentMessage, (u64, &InputPreprocessorMessageHandler, &Pe
|
||||||
true => self.name.clone(),
|
true => self.name.clone(),
|
||||||
false => self.name.clone() + FILE_SAVE_SUFFIX,
|
false => self.name.clone() + FILE_SAVE_SUFFIX,
|
||||||
};
|
};
|
||||||
responses.add(FrontendMessage::TriggerFileDownload {
|
responses.add(FrontendMessage::TriggerDownloadTextFile {
|
||||||
document: self.serialize_document(),
|
document: self.serialize_document(),
|
||||||
name,
|
name,
|
||||||
})
|
})
|
||||||
|
|
@ -968,7 +989,7 @@ impl DocumentMessageHandler {
|
||||||
) -> Option<Message> {
|
) -> Option<Message> {
|
||||||
// Prepare the node graph input image
|
// Prepare the node graph input image
|
||||||
|
|
||||||
let Some(node_network) = self.document_legacy.layer(&layer_path).ok().and_then(|layer| layer.as_node_graph().ok()) else {
|
let Some(node_network) = self.document_legacy.layer(&layer_path).ok().and_then(|layer| layer.as_layer_network().ok()) else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ struct ModifyInputsContext<'a> {
|
||||||
impl<'a> ModifyInputsContext<'a> {
|
impl<'a> ModifyInputsContext<'a> {
|
||||||
/// Get the node network from the document
|
/// Get the node network from the document
|
||||||
fn new(layer: &'a [LayerId], document: &'a mut Document, node_graph: &'a mut NodeGraphMessageHandler, responses: &'a mut VecDeque<Message>) -> Option<Self> {
|
fn new(layer: &'a [LayerId], document: &'a mut Document, node_graph: &'a mut NodeGraphMessageHandler, responses: &'a mut VecDeque<Message>) -> Option<Self> {
|
||||||
document.layer_mut(layer).ok().and_then(|layer| layer.as_node_graph_mut().ok()).map(|network| Self {
|
document.layer_mut(layer).ok().and_then(|layer| layer.as_layer_network_mut().ok()).map(|network| Self {
|
||||||
network,
|
network,
|
||||||
node_graph,
|
node_graph,
|
||||||
responses,
|
responses,
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ impl LayerBounds {
|
||||||
pub fn new(document: &Document, layer_path: &[u64]) -> Self {
|
pub fn new(document: &Document, layer_path: &[u64]) -> Self {
|
||||||
let layer = document.layer(layer_path).ok();
|
let layer = document.layer(layer_path).ok();
|
||||||
let bounds = layer
|
let bounds = layer
|
||||||
.and_then(|layer| layer.as_graph_frame().ok())
|
.and_then(|layer| layer.as_layer().ok())
|
||||||
.and_then(|frame| frame.as_vector_data().as_ref().map(|vector| vector.nonzero_bounding_box()))
|
.and_then(|frame| frame.as_vector_data().as_ref().map(|vector| vector.nonzero_bounding_box()))
|
||||||
.unwrap_or([DVec2::ZERO, DVec2::ONE]);
|
.unwrap_or([DVec2::ZERO, DVec2::ONE]);
|
||||||
let bounds_transform = DAffine2::IDENTITY;
|
let bounds_transform = DAffine2::IDENTITY;
|
||||||
|
|
|
||||||
|
|
@ -118,11 +118,14 @@ pub struct NodeGraphMessageHandler {
|
||||||
|
|
||||||
impl NodeGraphMessageHandler {
|
impl NodeGraphMessageHandler {
|
||||||
fn get_root_network<'a>(&self, document: &'a Document) -> Option<&'a graph_craft::document::NodeNetwork> {
|
fn get_root_network<'a>(&self, document: &'a Document) -> Option<&'a graph_craft::document::NodeNetwork> {
|
||||||
self.layer_path.as_ref().and_then(|path| document.layer(path).ok()).and_then(|layer| layer.as_node_graph().ok())
|
self.layer_path.as_ref().and_then(|path| document.layer(path).ok()).and_then(|layer| layer.as_layer_network().ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_root_network_mut<'a>(&self, document: &'a mut Document) -> Option<&'a mut graph_craft::document::NodeNetwork> {
|
fn get_root_network_mut<'a>(&self, document: &'a mut Document) -> Option<&'a mut graph_craft::document::NodeNetwork> {
|
||||||
self.layer_path.as_ref().and_then(|path| document.layer_mut(path).ok()).and_then(|layer| layer.as_node_graph_mut().ok())
|
self.layer_path
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|path| document.layer_mut(path).ok())
|
||||||
|
.and_then(|layer| layer.as_layer_network_mut().ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the active graph_craft NodeNetwork struct
|
/// Get the active graph_craft NodeNetwork struct
|
||||||
|
|
@ -726,7 +729,7 @@ impl MessageHandler<NodeGraphMessage, (&mut Document, &mut dyn Iterator<Item = &
|
||||||
let network = document
|
let network = document
|
||||||
.layer_mut(&layer_path)
|
.layer_mut(&layer_path)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|layer| layer.as_node_graph_mut().ok())
|
.and_then(|layer| layer.as_layer_network_mut().ok())
|
||||||
.and_then(|network| network.nested_network_mut(node_path));
|
.and_then(|network| network.nested_network_mut(node_path));
|
||||||
|
|
||||||
if let Some(network) = network {
|
if let Some(network) = network {
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,7 @@ fn static_nodes() -> Vec<DocumentNodeType> {
|
||||||
default: NodeInput::value(TaggedValue::ImageFrame(ImageFrame::empty()), true),
|
default: NodeInput::value(TaggedValue::ImageFrame(ImageFrame::empty()), true),
|
||||||
}],
|
}],
|
||||||
outputs: vec![],
|
outputs: vec![],
|
||||||
properties: |_document_node, _node_id, _context| node_properties::string_properties("The graph's output is drawn in the layer"),
|
properties: node_properties::output_properties,
|
||||||
},
|
},
|
||||||
DocumentNodeType {
|
DocumentNodeType {
|
||||||
name: "Image Frame",
|
name: "Image Frame",
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@ use document_legacy::Operation;
|
||||||
use glam::DVec2;
|
use glam::DVec2;
|
||||||
use graph_craft::document::value::TaggedValue;
|
use graph_craft::document::value::TaggedValue;
|
||||||
use graph_craft::document::{DocumentNode, NodeId, NodeInput};
|
use graph_craft::document::{DocumentNode, NodeId, NodeInput};
|
||||||
use graph_craft::imaginate_input::*;
|
use graph_craft::{concrete, imaginate_input::*};
|
||||||
use graphene_core::raster::{BlendMode, Color, ImageFrame, LuminanceCalculation, RedGreenBlue, RelativeAbsolute, SelectiveColorChoice};
|
use graphene_core::raster::{BlendMode, Color, ImageFrame, LuminanceCalculation, RedGreenBlue, RelativeAbsolute, SelectiveColorChoice};
|
||||||
use graphene_core::text::Font;
|
use graphene_core::text::Font;
|
||||||
use graphene_core::vector::style::{FillType, GradientType, LineCap, LineJoin};
|
use graphene_core::vector::style::{FillType, GradientType, LineCap, LineJoin};
|
||||||
use graphene_core::EditorApi;
|
use graphene_core::EditorApi;
|
||||||
|
use graphene_core::{Cow, Type, TypeDescriptor};
|
||||||
|
|
||||||
use super::document_node_types::NodePropertiesContext;
|
use super::document_node_types::NodePropertiesContext;
|
||||||
use super::{FrontendGraphDataType, IMAGINATE_NODE};
|
use super::{FrontendGraphDataType, IMAGINATE_NODE};
|
||||||
|
|
@ -512,6 +513,37 @@ pub fn blend_properties(document_node: &DocumentNode, node_id: NodeId, _context:
|
||||||
vec![backdrop, blend_mode, LayoutGroup::Row { widgets: opacity }]
|
vec![backdrop, blend_mode, LayoutGroup::Row { widgets: opacity }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn output_properties(_document_node: &DocumentNode, _node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
|
||||||
|
let output_type = context.executor.previous_output_type(context.layer_path);
|
||||||
|
let raster_output_type = concrete!(ImageFrame<Color>);
|
||||||
|
let disabled = match output_type {
|
||||||
|
Some(output_type) => output_type != raster_output_type,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let layer_path_1 = context.layer_path.to_vec();
|
||||||
|
let layer_path_2 = context.layer_path.to_vec();
|
||||||
|
|
||||||
|
let label = TextLabel::new("The graph's output is drawn in the layer").widget_holder();
|
||||||
|
let download_button = TextButton::new("Download Render Output")
|
||||||
|
.tooltip("Download the rendered image output as a PNG file")
|
||||||
|
.disabled(disabled)
|
||||||
|
.on_update(move |_| DocumentMessage::DownloadLayerImageOutput { layer_path: layer_path_1.clone() }.into())
|
||||||
|
.widget_holder();
|
||||||
|
let copy_button = TextButton::new("Copy Render Output")
|
||||||
|
.tooltip("Copy the rendered image output to the clipboard")
|
||||||
|
.disabled(disabled)
|
||||||
|
.on_update(move |_| DocumentMessage::CopyToClipboardLayerImageOutput { layer_path: layer_path_2.clone() }.into())
|
||||||
|
.widget_holder();
|
||||||
|
|
||||||
|
vec![
|
||||||
|
LayoutGroup::Row { widgets: vec![label] },
|
||||||
|
LayoutGroup::Row {
|
||||||
|
widgets: vec![download_button, WidgetHolder::related_separator(), copy_button],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mask_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
|
pub fn mask_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
|
||||||
let mask = color_widget(document_node, node_id, 1, "Stencil", ColorInput::default(), true);
|
let mask = color_widget(document_node, node_id, 1, "Stencil", ColorInput::default(), true);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,7 @@ fn load_existing_points(document: &DocumentMessageHandler) -> Option<(Vec<LayerI
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let layer_path = document.selected_layers().next()?.to_vec();
|
let layer_path = document.selected_layers().next()?.to_vec();
|
||||||
let network = document.document_legacy.layer(&layer_path).ok().and_then(|layer| layer.as_node_graph().ok())?;
|
let network = document.document_legacy.layer(&layer_path).ok().and_then(|layer| layer.as_layer_network().ok())?;
|
||||||
let brush_node = network.nodes.get(&0)?;
|
let brush_node = network.nodes.get(&0)?;
|
||||||
if brush_node.implementation != DocumentNodeImplementation::Unresolved("graphene_std::brush::BrushNode".into()) {
|
if brush_node.implementation != DocumentNodeImplementation::Unresolved("graphene_std::brush::BrushNode".into()) {
|
||||||
return None;
|
return None;
|
||||||
|
|
|
||||||
|
|
@ -843,7 +843,7 @@ impl Fsm for SelectToolFsmState {
|
||||||
// Check that only one layer is selected
|
// Check that only one layer is selected
|
||||||
if selected_layers.next().is_none() {
|
if selected_layers.next().is_none() {
|
||||||
if let Ok(layer) = document.document_legacy.layer(layer_path) {
|
if let Ok(layer) = document.document_legacy.layer(layer_path) {
|
||||||
if let Ok(network) = layer.as_node_graph() {
|
if let Ok(network) = layer.as_layer_network() {
|
||||||
if network.nodes.values().any(|node| node.name == "Text") {
|
if network.nodes.values().any(|node| node.name == "Text") {
|
||||||
responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::Text });
|
responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::Text });
|
||||||
responses.add(TextToolMessage::EditSelected);
|
responses.add(TextToolMessage::EditSelected);
|
||||||
|
|
|
||||||
|
|
@ -380,7 +380,7 @@ fn resize_overlays(overlays: &mut Vec<Vec<LayerId>>, responses: &mut VecDeque<Me
|
||||||
|
|
||||||
fn update_overlays(document: &DocumentMessageHandler, tool_data: &mut TextToolData, responses: &mut VecDeque<Message>, render_data: &RenderData) {
|
fn update_overlays(document: &DocumentMessageHandler, tool_data: &mut TextToolData, responses: &mut VecDeque<Message>, render_data: &RenderData) {
|
||||||
let get_bounds = |layer: &Layer, path: &[LayerId], document: &DocumentMessageHandler, render_data: &RenderData| {
|
let get_bounds = |layer: &Layer, path: &[LayerId], document: &DocumentMessageHandler, render_data: &RenderData| {
|
||||||
let node_graph = layer.as_node_graph().ok()?;
|
let node_graph = layer.as_layer_network().ok()?;
|
||||||
let node_id = get_text_node_id(node_graph)?;
|
let node_id = get_text_node_id(node_graph)?;
|
||||||
let document_node = node_graph.nodes.get(&node_id)?;
|
let document_node = node_graph.nodes.get(&node_id)?;
|
||||||
let (text, font, font_size) = TextToolData::extract_text_node_inputs(document_node)?;
|
let (text, font, font_size) = TextToolData::extract_text_node_inputs(document_node)?;
|
||||||
|
|
@ -410,7 +410,7 @@ fn update_overlays(document: &DocumentMessageHandler, tool_data: &mut TextToolDa
|
||||||
|
|
||||||
fn get_network<'a>(layer_path: &[LayerId], document: &'a DocumentMessageHandler) -> Option<&'a NodeNetwork> {
|
fn get_network<'a>(layer_path: &[LayerId], document: &'a DocumentMessageHandler) -> Option<&'a NodeNetwork> {
|
||||||
let layer = document.document_legacy.layer(layer_path).ok()?;
|
let layer = document.document_legacy.layer(layer_path).ok()?;
|
||||||
layer.as_node_graph().ok()
|
layer.as_layer_network().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_text_node_id(network: &NodeNetwork) -> Option<NodeId> {
|
fn get_text_node_id(network: &NodeNetwork) -> Option<NodeId> {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@ use std::borrow::Cow;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct NodeGraphExecutor {
|
pub struct NodeGraphExecutor {
|
||||||
executor: DynamicExecutor,
|
pub(crate) executor: DynamicExecutor,
|
||||||
|
// TODO: This is a memory leak since layers are never removed
|
||||||
|
pub(crate) last_output_type: HashMap<Vec<LayerId>, Option<Type>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NodeGraphExecutor {
|
impl NodeGraphExecutor {
|
||||||
|
|
@ -55,6 +57,10 @@ impl NodeGraphExecutor {
|
||||||
self.executor.introspect(path).flatten()
|
self.executor.introspect(path).flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn previous_output_type(&self, path: &[LayerId]) -> Option<Type> {
|
||||||
|
self.last_output_type.get(path).cloned().flatten()
|
||||||
|
}
|
||||||
|
|
||||||
/// Computes an input for a node in the graph
|
/// Computes an input for a node in the graph
|
||||||
pub fn compute_input<T: dyn_any::StaticType>(&mut self, old_network: &NodeNetwork, node_path: &[NodeId], mut input_index: usize, editor_api: Cow<EditorApi<'_>>) -> Result<T, String> {
|
pub fn compute_input<T: dyn_any::StaticType>(&mut self, old_network: &NodeNetwork, node_path: &[NodeId], mut input_index: usize, editor_api: Cow<EditorApi<'_>>) -> Result<T, String> {
|
||||||
let mut network = old_network.clone();
|
let mut network = old_network.clone();
|
||||||
|
|
@ -272,11 +278,13 @@ impl NodeGraphExecutor {
|
||||||
// Update the cached vector data on the layer
|
// Update the cached vector data on the layer
|
||||||
let vector_data: VectorData = dyn_any::downcast(boxed_node_graph_output).map(|v| *v)?;
|
let vector_data: VectorData = dyn_any::downcast(boxed_node_graph_output).map(|v| *v)?;
|
||||||
let transform = vector_data.transform.to_cols_array();
|
let transform = vector_data.transform.to_cols_array();
|
||||||
|
self.last_output_type.insert(layer_path.clone(), Some(concrete!(VectorData)));
|
||||||
responses.add(Operation::SetLayerTransform { path: layer_path.clone(), transform });
|
responses.add(Operation::SetLayerTransform { path: layer_path.clone(), transform });
|
||||||
responses.add(Operation::SetVectorData { path: layer_path, vector_data });
|
responses.add(Operation::SetVectorData { path: layer_path, vector_data });
|
||||||
} else {
|
} else {
|
||||||
// Attempt to downcast to an image frame
|
// Attempt to downcast to an image frame
|
||||||
let ImageFrame { image, transform } = dyn_any::downcast(boxed_node_graph_output).map(|image_frame| *image_frame)?;
|
let ImageFrame { image, transform } = dyn_any::downcast(boxed_node_graph_output).map(|image_frame| *image_frame)?;
|
||||||
|
self.last_output_type.insert(layer_path.clone(), Some(concrete!(ImageFrame<Color>)));
|
||||||
|
|
||||||
// Don't update the frame's transform if the new transform is DAffine2::ZERO.
|
// Don't update the frame's transform if the new transform is DAffine2::ZERO.
|
||||||
let transform = (!transform.abs_diff_eq(DAffine2::ZERO, f64::EPSILON)).then_some(transform.to_cols_array());
|
let transform = (!transform.abs_diff_eq(DAffine2::ZERO, f64::EPSILON)).then_some(transform.to_cols_array());
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { type Editor } from "@graphite/wasm-communication/editor";
|
import { type Editor } from "@graphite/wasm-communication/editor";
|
||||||
import { TriggerTextCopy } from "@graphite/wasm-communication/messages";
|
import { TriggerTextCopy } from "@graphite/wasm-communication/messages";
|
||||||
|
import { imageToPNG } from "~src/utility-functions/rasterization";
|
||||||
|
|
||||||
export function createClipboardManager(editor: Editor): void {
|
export function createClipboardManager(editor: Editor): void {
|
||||||
// Subscribe to process backend event
|
// Subscribe to process backend event
|
||||||
|
|
@ -8,3 +9,20 @@ export function createClipboardManager(editor: Editor): void {
|
||||||
navigator.clipboard?.writeText?.(triggerTextCopy.copyText);
|
navigator.clipboard?.writeText?.(triggerTextCopy.copyText);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function copyToClipboardFileURL(url: string): Promise<void> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// TODO: Remove this if/when we end up returning PNG directly from the backend
|
||||||
|
const pngBlob = await imageToPNG(blob);
|
||||||
|
|
||||||
|
const clipboardItem: Record<string, Blob> = {};
|
||||||
|
clipboardItem[pngBlob.type] = pngBlob;
|
||||||
|
const data = [new ClipboardItem(clipboardItem)];
|
||||||
|
|
||||||
|
// Note: if this image has transparency, it will be lost and appear as black due to limitations of the way browsers handle copying transparent images
|
||||||
|
// This even happens if you just open a regular transparent PNG file in a browser tab, right click > copy, and paste it somewhere (the transparency will show up as black)
|
||||||
|
// This is true, at least, on Windows (it's worth checking on other OSs though)
|
||||||
|
navigator.clipboard.write(data);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,28 @@
|
||||||
|
|
||||||
import {writable} from "svelte/store";
|
import {writable} from "svelte/store";
|
||||||
|
|
||||||
import { downloadFileText, downloadFileBlob, upload } from "@graphite/utility-functions/files";
|
import { downloadFileText, downloadFileBlob, upload, downloadFileURL } from "@graphite/utility-functions/files";
|
||||||
import { imaginateGenerate, imaginateCheckConnection, imaginateTerminate, updateBackendImage } from "@graphite/utility-functions/imaginate";
|
import { imaginateGenerate, imaginateCheckConnection, imaginateTerminate, updateBackendImage } from "@graphite/utility-functions/imaginate";
|
||||||
import { extractPixelData, rasterizeSVG, rasterizeSVGCanvas } from "@graphite/utility-functions/rasterization";
|
import { extractPixelData, imageToPNG, rasterizeSVG, rasterizeSVGCanvas } from "@graphite/utility-functions/rasterization";
|
||||||
import { type Editor } from "@graphite/wasm-communication/editor";
|
import { type Editor } from "@graphite/wasm-communication/editor";
|
||||||
import {
|
import {
|
||||||
type FrontendDocumentDetails,
|
type FrontendDocumentDetails,
|
||||||
TriggerFileDownload,
|
TriggerCopyToClipboardBlobUrl,
|
||||||
TriggerImport,
|
TriggerDownloadBlobUrl,
|
||||||
TriggerOpenDocument,
|
TriggerDownloadRaster,
|
||||||
TriggerRasterDownload,
|
TriggerDownloadTextFile,
|
||||||
|
TriggerImaginateCheckServerStatus,
|
||||||
TriggerImaginateGenerate,
|
TriggerImaginateGenerate,
|
||||||
TriggerImaginateTerminate,
|
TriggerImaginateTerminate,
|
||||||
TriggerImaginateCheckServerStatus,
|
TriggerImport,
|
||||||
|
TriggerOpenDocument,
|
||||||
TriggerRasterizeRegionBelowLayer,
|
TriggerRasterizeRegionBelowLayer,
|
||||||
UpdateActiveDocument,
|
|
||||||
UpdateOpenDocumentsList,
|
|
||||||
UpdateImageData,
|
|
||||||
TriggerRevokeBlobUrl,
|
TriggerRevokeBlobUrl,
|
||||||
|
UpdateActiveDocument,
|
||||||
|
UpdateImageData,
|
||||||
|
UpdateOpenDocumentsList,
|
||||||
} from "@graphite/wasm-communication/messages";
|
} from "@graphite/wasm-communication/messages";
|
||||||
|
import { copyToClipboardFileURL } from "~src/io-managers/clipboard";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||||
export function createPortfolioState(editor: Editor) {
|
export function createPortfolioState(editor: Editor) {
|
||||||
|
|
@ -55,10 +58,22 @@ export function createPortfolioState(editor: Editor) {
|
||||||
const imageData = await extractPixelData(new Blob([data.content], { type: data.type }));
|
const imageData = await extractPixelData(new Blob([data.content], { type: data.type }));
|
||||||
editor.instance.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height);
|
editor.instance.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height);
|
||||||
});
|
});
|
||||||
editor.subscriptions.subscribeJsMessage(TriggerFileDownload, (triggerFileDownload) => {
|
editor.subscriptions.subscribeJsMessage(TriggerDownloadTextFile, (triggerFileDownload) => {
|
||||||
downloadFileText(triggerFileDownload.name, triggerFileDownload.document);
|
downloadFileText(triggerFileDownload.name, triggerFileDownload.document);
|
||||||
});
|
});
|
||||||
editor.subscriptions.subscribeJsMessage(TriggerRasterDownload, async (triggerRasterDownload) => {
|
editor.subscriptions.subscribeJsMessage(TriggerDownloadBlobUrl, async (triggerDownloadBlobUrl) => {
|
||||||
|
const data = await fetch(triggerDownloadBlobUrl.blobUrl);
|
||||||
|
const blob = await data.blob();
|
||||||
|
|
||||||
|
// TODO: Remove this if/when we end up returning PNG directly from the backend
|
||||||
|
const pngBlob = await imageToPNG(blob);
|
||||||
|
|
||||||
|
downloadFileBlob(triggerDownloadBlobUrl.layerName, pngBlob);
|
||||||
|
});
|
||||||
|
editor.subscriptions.subscribeJsMessage(TriggerCopyToClipboardBlobUrl, (triggerDownloadBlobUrl) => {
|
||||||
|
copyToClipboardFileURL(triggerDownloadBlobUrl.blobUrl);
|
||||||
|
});
|
||||||
|
editor.subscriptions.subscribeJsMessage(TriggerDownloadRaster, async (triggerRasterDownload) => {
|
||||||
const { svg, name, mime, size } = triggerRasterDownload;
|
const { svg, name, mime, size } = triggerRasterDownload;
|
||||||
|
|
||||||
// Fill the canvas with white if it'll be a JPEG (which does not support transparency and defaults to black)
|
// Fill the canvas with white if it'll be a JPEG (which does not support transparency and defaults to black)
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,26 @@ export async function rasterizeSVG(svg: string, width: number, height: number, m
|
||||||
|
|
||||||
/// Convert an image source (e.g. PNG document) into pixel data, a width, and a height
|
/// Convert an image source (e.g. PNG document) into pixel data, a width, and a height
|
||||||
export async function extractPixelData(imageData: ImageBitmapSource): Promise<ImageData> {
|
export async function extractPixelData(imageData: ImageBitmapSource): Promise<ImageData> {
|
||||||
|
const canvasContext = await imageToCanvasContext(imageData);
|
||||||
|
const width = canvasContext.canvas.width;
|
||||||
|
const height = canvasContext.canvas.height;
|
||||||
|
|
||||||
|
return canvasContext.getImageData(0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert an image source (e.g. BMP document) into a PNG blob
|
||||||
|
export async function imageToPNG(imageData: ImageBitmapSource): Promise<Blob> {
|
||||||
|
const canvasContext = await imageToCanvasContext(imageData);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvasContext.canvas.toBlob((pngBlob) => {
|
||||||
|
if (pngBlob) resolve(pngBlob);
|
||||||
|
else reject("Converting canvas to blob data failed in imageToPNG()");
|
||||||
|
}, "image/png");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function imageToCanvasContext(imageData: ImageBitmapSource): Promise<CanvasRenderingContext2D> {
|
||||||
// Special handling to rasterize an SVG file
|
// Special handling to rasterize an SVG file
|
||||||
let svgImageData;
|
let svgImageData;
|
||||||
if (imageData instanceof File && imageData.type === "image/svg+xml") {
|
if (imageData instanceof File && imageData.type === "image/svg+xml") {
|
||||||
|
|
@ -92,5 +112,5 @@ export async function extractPixelData(imageData: ImageBitmapSource): Promise<Im
|
||||||
if (!context) throw new Error("Could not create canvas context");
|
if (!context) throw new Error("Could not create canvas context");
|
||||||
context.drawImage(image, 0, 0, image.width, image.height, 0, 0, width, height);
|
context.drawImage(image, 0, 0, image.width, image.height, 0, 0, width, height);
|
||||||
|
|
||||||
return context.getImageData(0, 0, width, height);
|
return context;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -498,12 +498,6 @@ export class UpdateMouseCursor extends JsMessage {
|
||||||
readonly cursor!: MouseCursorIcon;
|
readonly cursor!: MouseCursorIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TriggerFileDownload extends JsMessage {
|
|
||||||
readonly document!: string;
|
|
||||||
|
|
||||||
readonly name!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TriggerLoadAutoSaveDocuments extends JsMessage { }
|
export class TriggerLoadAutoSaveDocuments extends JsMessage { }
|
||||||
|
|
||||||
export class TriggerLoadPreferences extends JsMessage { }
|
export class TriggerLoadPreferences extends JsMessage { }
|
||||||
|
|
@ -514,7 +508,17 @@ export class TriggerImport extends JsMessage { }
|
||||||
|
|
||||||
export class TriggerPaste extends JsMessage { }
|
export class TriggerPaste extends JsMessage { }
|
||||||
|
|
||||||
export class TriggerRasterDownload extends JsMessage {
|
export class TriggerCopyToClipboardBlobUrl extends JsMessage {
|
||||||
|
readonly blobUrl!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TriggerDownloadBlobUrl extends JsMessage {
|
||||||
|
readonly layerName!: string;
|
||||||
|
|
||||||
|
readonly blobUrl!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TriggerDownloadRaster extends JsMessage {
|
||||||
readonly svg!: string;
|
readonly svg!: string;
|
||||||
|
|
||||||
readonly name!: string;
|
readonly name!: string;
|
||||||
|
|
@ -525,6 +529,12 @@ export class TriggerRasterDownload extends JsMessage {
|
||||||
readonly size!: XY;
|
readonly size!: XY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class TriggerDownloadTextFile extends JsMessage {
|
||||||
|
readonly document!: string;
|
||||||
|
|
||||||
|
readonly name!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class TriggerImaginateCheckServerStatus extends JsMessage {
|
export class TriggerImaginateCheckServerStatus extends JsMessage {
|
||||||
readonly hostname!: string;
|
readonly hostname!: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1384,12 +1394,14 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||||
DisplayEditableTextboxTransform,
|
DisplayEditableTextboxTransform,
|
||||||
DisplayRemoveEditableTextbox,
|
DisplayRemoveEditableTextbox,
|
||||||
TriggerAboutGraphiteLocalizedCommitDate,
|
TriggerAboutGraphiteLocalizedCommitDate,
|
||||||
|
TriggerCopyToClipboardBlobUrl,
|
||||||
|
TriggerDownloadBlobUrl,
|
||||||
|
TriggerDownloadRaster,
|
||||||
|
TriggerDownloadTextFile,
|
||||||
|
TriggerFontLoad,
|
||||||
TriggerImaginateCheckServerStatus,
|
TriggerImaginateCheckServerStatus,
|
||||||
TriggerImaginateGenerate,
|
TriggerImaginateGenerate,
|
||||||
TriggerImaginateTerminate,
|
TriggerImaginateTerminate,
|
||||||
TriggerRasterizeRegionBelowLayer,
|
|
||||||
TriggerFileDownload,
|
|
||||||
TriggerFontLoad,
|
|
||||||
TriggerImport,
|
TriggerImport,
|
||||||
TriggerIndexedDbRemoveDocument,
|
TriggerIndexedDbRemoveDocument,
|
||||||
TriggerIndexedDbWriteDocument,
|
TriggerIndexedDbWriteDocument,
|
||||||
|
|
@ -1397,7 +1409,7 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||||
TriggerLoadPreferences,
|
TriggerLoadPreferences,
|
||||||
TriggerOpenDocument,
|
TriggerOpenDocument,
|
||||||
TriggerPaste,
|
TriggerPaste,
|
||||||
TriggerRasterDownload,
|
TriggerRasterizeRegionBelowLayer,
|
||||||
TriggerRefreshBoundsOfViewports,
|
TriggerRefreshBoundsOfViewports,
|
||||||
TriggerRevokeBlobUrl,
|
TriggerRevokeBlobUrl,
|
||||||
TriggerSavePreferences,
|
TriggerSavePreferences,
|
||||||
|
|
@ -1415,8 +1427,8 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||||
UpdateDocumentModeLayout,
|
UpdateDocumentModeLayout,
|
||||||
UpdateDocumentOverlays,
|
UpdateDocumentOverlays,
|
||||||
UpdateDocumentRulers,
|
UpdateDocumentRulers,
|
||||||
UpdateEyedropperSamplingState,
|
|
||||||
UpdateDocumentScrollbars,
|
UpdateDocumentScrollbars,
|
||||||
|
UpdateEyedropperSamplingState,
|
||||||
UpdateImageData,
|
UpdateImageData,
|
||||||
UpdateInputHints,
|
UpdateInputHints,
|
||||||
UpdateLayerTreeOptionsLayout,
|
UpdateLayerTreeOptionsLayout,
|
||||||
|
|
@ -1429,9 +1441,9 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||||
UpdateOpenDocumentsList,
|
UpdateOpenDocumentsList,
|
||||||
UpdatePropertyPanelOptionsLayout,
|
UpdatePropertyPanelOptionsLayout,
|
||||||
UpdatePropertyPanelSectionsLayout,
|
UpdatePropertyPanelSectionsLayout,
|
||||||
UpdateZoomWithScroll,
|
|
||||||
UpdateToolOptionsLayout,
|
UpdateToolOptionsLayout,
|
||||||
UpdateToolShelfLayout,
|
UpdateToolShelfLayout,
|
||||||
UpdateWorkingColorsLayout,
|
UpdateWorkingColorsLayout,
|
||||||
|
UpdateZoomWithScroll,
|
||||||
} as const;
|
} as const;
|
||||||
export type JsMessageType = keyof typeof messageMakers;
|
export type JsMessageType = keyof typeof messageMakers;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue