From 904cf09c792da2179820c231b7b9650243fe32f7 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 28 Sep 2024 00:19:43 -0700 Subject: [PATCH] Add drag-and-drop and copy-paste file importing/opening throughout the UI (#2012) * Add file importing by dragging and dropping throughout the UI * Disable comment-profiling-changes.yaml * Fix CI --- .../workflows/comment-profiling-changes.yaml | 2 + editor/src/consts.rs | 4 + .../new_document_dialog_message_handler.rs | 2 + .../simple_dialogs/close_document_dialog.rs | 12 +- .../portfolio/document/document_message.rs | 14 +- .../document/document_message_handler.rs | 131 ++++++++++--- .../graph_operation_message_handler.rs | 175 +++++++++--------- .../document/graph_operation/utility_types.rs | 38 ++-- .../menu_bar/menu_bar_message_handler.rs | 1 - .../messages/portfolio/portfolio_message.rs | 14 ++ .../portfolio/portfolio_message_handler.rs | 66 ++++++- .../graph_modification_utils.rs | 2 +- .../messages/tool/tool_messages/brush_tool.rs | 3 +- .../tool/tool_messages/ellipse_tool.rs | 3 +- .../tool/tool_messages/freehand_tool.rs | 3 +- .../messages/tool/tool_messages/line_tool.rs | 4 +- .../messages/tool/tool_messages/pen_tool.rs | 5 +- .../tool/tool_messages/polygon_tool.rs | 3 +- .../tool/tool_messages/rectangle_tool.rs | 3 +- .../tool/tool_messages/spline_tool.rs | 4 +- .../src/components/layout/LayoutCol.svelte | 2 +- .../src/components/layout/LayoutRow.svelte | 2 +- .../src/components/panels/Document.svelte | 26 ++- frontend/src/components/panels/Layers.svelte | 50 ++++- .../components/window/workspace/Panel.svelte | 38 +++- frontend/src/io-managers/drag.ts | 4 +- frontend/src/io-managers/input.ts | 116 +++++++----- frontend/src/state-providers/document.ts | 8 +- frontend/src/state-providers/portfolio.ts | 15 +- frontend/src/utility-functions/files.ts | 14 +- frontend/src/wasm-communication/messages.ts | 3 +- frontend/tsconfig.json | 6 +- frontend/wasm/src/editor_api.rs | 46 ++++- node-graph/gcore/src/vector/vector_nodes.rs | 2 +- website/other/bezier-rs-demos/tsconfig.json | 6 +- 35 files changed, 573 insertions(+), 254 deletions(-) diff --git a/.github/workflows/comment-profiling-changes.yaml b/.github/workflows/comment-profiling-changes.yaml index 8ff65294af..6f7fc17dba 100644 --- a/.github/workflows/comment-profiling-changes.yaml +++ b/.github/workflows/comment-profiling-changes.yaml @@ -9,6 +9,8 @@ env: jobs: profile: + # TODO(TrueDoctor): Fix and reenable this action + if: false runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 89385ad2f5..37287a9dfa 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -40,6 +40,9 @@ pub const SLOWING_DIVISOR: f64 = 10.; pub const NUDGE_AMOUNT: f64 = 1.; pub const BIG_NUDGE_AMOUNT: f64 = 10.; +// Tools +pub const DEFAULT_STROKE_WIDTH: f64 = 2.; + // Select tool pub const SELECTION_TOLERANCE: f64 = 5.; pub const SELECTION_DRAG_ANGLE: f64 = 90.; @@ -65,6 +68,7 @@ pub const LINE_ROTATE_SNAP_ANGLE: f64 = 15.; // Brush tool pub const BRUSH_SIZE_CHANGE_KEYBOARD: f64 = 5.; +pub const DEFAULT_BRUSH_SIZE: f64 = 20.; // Scrollbars pub const SCROLLBAR_SPACING: f64 = 0.1; diff --git a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs index efad5063ea..ff4b27ba57 100644 --- a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs +++ b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs @@ -34,6 +34,8 @@ impl MessageHandler for NewDocumentDialogMessageHa responses.add(NodeGraphMessage::RunDocumentGraph); responses.add(NodeGraphMessage::UpdateNewNodeGraph); + + // TODO: Figure out how to get StartBuffer to work here so we can delete this and use `DocumentMessage::ZoomCanvasToFitAll` instead responses.add(Message::StartBuffer); responses.add(FrontendMessage::TriggerDelayedZoomCanvasToFitAll); } diff --git a/editor/src/messages/dialog/simple_dialogs/close_document_dialog.rs b/editor/src/messages/dialog/simple_dialogs/close_document_dialog.rs index 8939b8992a..d370b2fde8 100644 --- a/editor/src/messages/dialog/simple_dialogs/close_document_dialog.rs +++ b/editor/src/messages/dialog/simple_dialogs/close_document_dialog.rs @@ -41,12 +41,22 @@ impl DialogLayoutHolder for CloseDocumentDialog { impl LayoutHolder for CloseDocumentDialog { fn layout(&self) -> Layout { + let max_length = 60; + let max_one_line_length = 40; + + let mut name = self.document_name.clone(); + + name.truncate(max_length); + let ellipsis = if self.document_name.len() > max_length { "…" } else { "" }; + + let break_lines = if self.document_name.len() > max_one_line_length { '\n' } else { ' ' }; + Layout::WidgetLayout(WidgetLayout::new(vec![ LayoutGroup::Row { widgets: vec![TextLabel::new("Save document before closing it?").bold(true).widget_holder()], }, LayoutGroup::Row { - widgets: vec![TextLabel::new(format!("\"{}\" has unsaved changes", self.document_name)).multiline(true).widget_holder()], + widgets: vec![TextLabel::new(format!("\"{name}{ellipsis}\"{break_lines}has unsaved changes")).multiline(true).widget_holder()], }, ])) } diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index cd4cccd3c2..7a7c87dba5 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -77,13 +77,6 @@ pub enum DocumentMessage { imaginate_node: Vec, then_generate: bool, }, - ImportSvg { - id: NodeId, - svg: String, - transform: DAffine2, - parent: LayerNodeIdentifier, - insert_index: usize, - }, MoveSelectedLayersTo { parent: LayerNodeIdentifier, insert_index: usize, @@ -98,12 +91,16 @@ pub enum DocumentMessage { resize_opposite_corner: Key, }, PasteImage { + name: Option, image: Image, mouse: Option<(f64, f64)>, + parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>, }, PasteSvg { + name: Option, svg: String, mouse: Option<(f64, f64)>, + parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>, }, Redo, RenameDocument { @@ -176,6 +173,9 @@ pub enum DocumentMessage { PTZUpdate, SelectionStepBack, SelectionStepForward, + WrapContentInArtboard { + place_artboard_at_origin: bool, + }, ZoomCanvasTo100Percent, ZoomCanvasTo200Percent, ZoomCanvasToFitAll, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index cfe6b08108..ce43a36c7d 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1,9 +1,10 @@ +use super::node_graph::document_node_definitions; use super::node_graph::utility_types::Transform; use super::overlays::utility_types::Pivot; use super::utility_types::clipboards::Clipboard; use super::utility_types::error::EditorError; use super::utility_types::misc::{SnappingOptions, SnappingState, GET_SNAP_BOX_FUNCTIONS, GET_SNAP_GEOMETRY_FUNCTIONS}; -use super::utility_types::network_interface::{NodeNetworkInterface, TransactionStatus}; +use super::utility_types::network_interface::{self, NodeNetworkInterface, TransactionStatus}; use super::utility_types::nodes::{CollapsedLayers, SelectedNodes}; use crate::application::{generate_uuid, GRAPHITE_GIT_COMMIT_HASH}; use crate::consts::{ASYMPTOTIC_EFFECT, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_SAVE_SUFFIX, SCALE_EFFECT, SCROLLBAR_SPACING, VIEWPORT_ROTATE_SNAP_INTERVAL}; @@ -18,7 +19,7 @@ use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, use crate::messages::portfolio::document::utility_types::nodes::RawBuffer; use crate::messages::portfolio::utility_types::PersistentData; use crate::messages::prelude::*; -use crate::messages::tool::common_functionality::graph_modification_utils::{get_blend_mode, get_opacity}; +use crate::messages::tool::common_functionality::graph_modification_utils::{self, get_blend_mode, get_opacity}; use crate::messages::tool::tool_messages::select_tool::SelectToolPointerKeys; use crate::messages::tool::tool_messages::tool_prelude::Key; use crate::messages::tool::utility_types::ToolType; @@ -29,7 +30,7 @@ use graph_craft::document::{NodeId, NodeNetwork, OldNodeNetwork}; use graphene_core::raster::{BlendMode, ImageFrame}; use graphene_core::vector::style::ViewMode; -use glam::{DAffine2, DVec2}; +use glam::{DAffine2, DVec2, IVec2}; pub struct DocumentMessageData<'a> { pub document_id: DocumentId, @@ -380,7 +381,11 @@ impl MessageHandler> for DocumentMessag let Some(bounds) = self.metadata().bounding_box_document(layer) else { continue }; let name = self.network_interface.frontend_display_name(&layer.to_node(), &[]); - let transform = self.metadata().document_to_viewport * DAffine2::from_translation(bounds[0].min(bounds[1]) - DVec2::Y * 4.); + + let (_, angle, translation) = self.metadata().document_to_viewport.to_scale_angle_translation(); + let translation = translation + bounds[0].min(bounds[1]) - DVec2::Y * 4.; + let transform = DAffine2::from_angle_translation(angle, translation); + overlay_context.text_with_transform(&name, COLOR_OVERLAY_GRAY, None, transform, Pivot::BottomLeft); } } @@ -498,7 +503,7 @@ impl MessageHandler> for DocumentMessag let insert_index = DocumentMessageHandler::get_calculated_insert_index(self.metadata(), self.network_interface.selected_nodes(&[]).unwrap(), parent); let node_id = NodeId(generate_uuid()); - let new_group_node = super::node_graph::document_node_definitions::resolve_document_node_type("Merge") + let new_group_node = document_node_definitions::resolve_document_node_type("Merge") .expect("Failed to create merge node") .default_node_template(); responses.add(NodeGraphMessage::InsertNode { @@ -546,23 +551,6 @@ impl MessageHandler> for DocumentMessag responses.add(DocumentMessage::ImaginateGenerate { imaginate_node }); } } - DocumentMessage::ImportSvg { - id, - svg, - transform, - parent, - insert_index, - } => { - responses.add(DocumentMessage::StartTransaction); - responses.add(GraphOperationMessage::NewSvg { - id, - svg, - transform, - parent, - insert_index, - }); - responses.add(DocumentMessage::EndTransaction); - } DocumentMessage::MoveSelectedLayersTo { parent, insert_index } => { if !self.selection_network_path.is_empty() { log::error!("Moving selected layers is only supported for the Document Network"); @@ -608,7 +596,7 @@ impl MessageHandler> for DocumentMessag let layers_to_move = self.network_interface.shallowest_unique_layers_sorted(&self.selection_network_path); // Offset the index for layers to move that are below another layer to move. For example when moving 1 and 2 between 3 and 4, 2 should be inserted at the same index as 1 since 1 is moved first. - let layers_to_move_with_insert_offset: Vec<(LayerNodeIdentifier, usize)> = layers_to_move + let layers_to_move_with_insert_offset = layers_to_move .iter() .map(|layer| { if layer.parent(self.metadata()) != Some(parent) { @@ -727,7 +715,12 @@ impl MessageHandler> for DocumentMessag } } } - DocumentMessage::PasteImage { image, mouse } => { + DocumentMessage::PasteImage { + name, + image, + mouse, + parent_and_insert_index, + } => { // All the image's pixels have been converted to 0..=1, linear, and premultiplied by `Color::from_rgba8_srgb` let image_size = DVec2::new(image.width as f64, image.height as f64); @@ -744,12 +737,27 @@ impl MessageHandler> for DocumentMessag let transform = center_in_viewport_layerspace * fit_image_size; + let layer_node_id = NodeId(generate_uuid()); + let layer_id = LayerNodeIdentifier::new_unchecked(layer_node_id); + responses.add(DocumentMessage::AddTransaction); let image_frame = ImageFrame { image, ..Default::default() }; + let layer = graph_modification_utils::new_image_layer(image_frame, layer_node_id, self.new_layer_parent(true), responses); - use crate::messages::tool::common_functionality::graph_modification_utils; - let layer = graph_modification_utils::new_image_layer(image_frame, NodeId(generate_uuid()), self.new_layer_parent(true), responses); + if let Some(name) = name { + responses.add(NodeGraphMessage::SetDisplayName { + node_id: layer.to_node(), + alias: name, + }); + } + if let Some((parent, insert_index)) = parent_and_insert_index { + responses.add(NodeGraphMessage::MoveLayerToStack { + layer: layer_id, + parent, + insert_index, + }); + } // `layer` cannot be `ROOT_PARENT` since it is the newly created layer responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }); @@ -764,12 +772,37 @@ impl MessageHandler> for DocumentMessag // Force chosen tool to be Select Tool after importing image. responses.add(ToolMessage::ActivateTool { tool_type: ToolType::Select }); } - DocumentMessage::PasteSvg { svg, mouse } => { - use crate::messages::tool::common_functionality::graph_modification_utils; - let viewport_location = mouse.map_or(ipp.viewport_bounds.center() + ipp.viewport_bounds.top_left, |pos| pos.into()); + DocumentMessage::PasteSvg { + name, + svg, + mouse, + parent_and_insert_index, + } => { let document_to_viewport = self.navigation_handler.calculate_offset_transform(ipp.viewport_bounds.center(), &self.document_ptz); + let viewport_location = mouse.map_or(ipp.viewport_bounds.center() + ipp.viewport_bounds.top_left, |pos| pos.into()); let center_in_viewport = DAffine2::from_translation(document_to_viewport.inverse().transform_point2(viewport_location - ipp.viewport_bounds.top_left)); - let layer = graph_modification_utils::new_svg_layer(svg, center_in_viewport, NodeId(generate_uuid()), self.new_layer_parent(true), responses); + + let layer_node_id = NodeId(generate_uuid()); + let layer_id = LayerNodeIdentifier::new_unchecked(layer_node_id); + + responses.add(DocumentMessage::AddTransaction); + + let layer = graph_modification_utils::new_svg_layer(svg, center_in_viewport, layer_node_id, self.new_layer_parent(true), responses); + + if let Some(name) = name { + responses.add(NodeGraphMessage::SetDisplayName { + node_id: layer.to_node(), + alias: name, + }); + } + if let Some((parent, insert_index)) = parent_and_insert_index { + responses.add(NodeGraphMessage::MoveLayerToStack { + layer: layer_id, + parent, + insert_index, + }); + } + responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }); responses.add(ToolMessage::ActivateTool { tool_type: ToolType::Select }); } @@ -1181,6 +1214,44 @@ impl MessageHandler> for DocumentMessag self.network_interface.selection_step_forward(&self.selection_network_path); responses.add(BroadcastEvent::SelectionChanged); } + DocumentMessage::WrapContentInArtboard { place_artboard_at_origin } => { + // Get bounding box of all layers + let bounds = self.network_interface.document_bounds_document_space(false); + let Some(bounds) = bounds else { return }; + let bounds_rounded_dimensions = (bounds[1] - bounds[0]).round(); + + // Create an artboard and set its dimensions to the bounding box size and location + let node_id = NodeId(generate_uuid()); + let node_layer_id = LayerNodeIdentifier::new_unchecked(node_id); + let new_artboard_node = document_node_definitions::resolve_document_node_type("Artboard") + .expect("Failed to create artboard node") + .default_node_template(); + responses.add(NodeGraphMessage::InsertNode { + node_id, + node_template: new_artboard_node, + }); + responses.add(NodeGraphMessage::ShiftNodePosition { node_id, x: 15, y: -3 }); + responses.add(GraphOperationMessage::ResizeArtboard { + layer: LayerNodeIdentifier::new_unchecked(node_id), + location: if place_artboard_at_origin { IVec2::ZERO } else { bounds[0].round().as_ivec2() }, + dimensions: bounds_rounded_dimensions.as_ivec2(), + }); + + // Connect the current output data to the artboard's input data, and the artboard's output to the document output + responses.add(NodeGraphMessage::InsertNodeBetween { + node_id, + input_connector: network_interface::InputConnector::Export(0), + insert_node_input_index: 1, + }); + + // Shift the content by half its width and height so it gets centered in the artboard + responses.add(GraphOperationMessage::TransformChange { + layer: node_layer_id, + transform: DAffine2::from_translation(bounds_rounded_dimensions / 2.), + transform_in: TransformIn::Local, + skip_rerender: true, + }); + } DocumentMessage::ZoomCanvasTo100Percent => { responses.add_front(NavigationMessage::CanvasZoomSet { zoom_factor: 1. }); } diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index f4b4a38b31..5aab007a42 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -173,7 +173,7 @@ impl MessageHandler> for Gr GraphOperationMessage::NewVectorLayer { id, subpaths, parent, insert_index } => { let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); let layer = modify_inputs.create_layer(id); - modify_inputs.insert_vector_data(subpaths, layer); + modify_inputs.insert_vector_data(subpaths, layer, true, true, true); network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); responses.add(NodeGraphMessage::RunDocumentGraph); } @@ -228,6 +228,10 @@ impl MessageHandler> for Gr }; let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); + let size = tree.size(); + let offset_to_center = DVec2::new(size.width() as f64, size.height() as f64) / -2.; + let transform = transform * DAffine2::from_translation(offset_to_center); + import_usvg_node(&mut modify_inputs, &usvg::Node::Group(Box::new(tree.root().clone())), transform, id, parent, insert_index); } } @@ -260,15 +264,20 @@ fn import_usvg_node(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, usvg::Node::Path(path) => { let subpaths = convert_usvg_path(path); let bounds = subpaths.iter().filter_map(|subpath| subpath.bounding_box()).reduce(Quad::combine_bounds).unwrap_or_default(); - modify_inputs.insert_vector_data(subpaths, layer); + + modify_inputs.insert_vector_data(subpaths, layer, true, path.fill().is_some(), path.stroke().is_some()); if let Some(transform_node_id) = modify_inputs.existing_node_id("Transform") { transform_utils::update_transform(modify_inputs.network_interface, &transform_node_id, transform * usvg_transform(node.abs_transform())); } - let bounds_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); - apply_usvg_fill(path.fill(), modify_inputs, transform * usvg_transform(node.abs_transform()), bounds_transform); - apply_usvg_stroke(path.stroke(), modify_inputs, transform * usvg_transform(node.abs_transform())); + if let Some(fill) = path.fill() { + let bounds_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); + apply_usvg_fill(fill, modify_inputs, transform * usvg_transform(node.abs_transform()), bounds_transform); + } + if let Some(stroke) = path.stroke() { + apply_usvg_stroke(stroke, modify_inputs, transform * usvg_transform(node.abs_transform())); + } } usvg::Node::Image(_image) => { warn!("Skip image") @@ -281,96 +290,90 @@ fn import_usvg_node(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, } } -fn apply_usvg_stroke(stroke: Option<&usvg::Stroke>, modify_inputs: &mut ModifyInputsContext, transform: DAffine2) { - if let Some(stroke) = stroke { - if let usvg::Paint::Color(color) = &stroke.paint() { - modify_inputs.stroke_set(Stroke { - color: Some(usvg_color(*color, stroke.opacity().get())), - weight: stroke.width().get() as f64, - dash_lengths: stroke.dasharray().as_ref().map(|lengths| lengths.iter().map(|&length| length as f64).collect()).unwrap_or_default(), - dash_offset: stroke.dashoffset() as f64, - line_cap: match stroke.linecap() { - usvg::LineCap::Butt => LineCap::Butt, - usvg::LineCap::Round => LineCap::Round, - usvg::LineCap::Square => LineCap::Square, - }, - line_join: match stroke.linejoin() { - usvg::LineJoin::Miter => LineJoin::Miter, - usvg::LineJoin::MiterClip => LineJoin::Miter, - usvg::LineJoin::Round => LineJoin::Round, - usvg::LineJoin::Bevel => LineJoin::Bevel, - }, - line_join_miter_limit: stroke.miterlimit().get() as f64, - transform, - }) - } else { - warn!("Skip non-solid stroke") - } +fn apply_usvg_stroke(stroke: &usvg::Stroke, modify_inputs: &mut ModifyInputsContext, transform: DAffine2) { + if let usvg::Paint::Color(color) = &stroke.paint() { + modify_inputs.stroke_set(Stroke { + color: Some(usvg_color(*color, stroke.opacity().get())), + weight: stroke.width().get() as f64, + dash_lengths: stroke.dasharray().as_ref().map(|lengths| lengths.iter().map(|&length| length as f64).collect()).unwrap_or_default(), + dash_offset: stroke.dashoffset() as f64, + line_cap: match stroke.linecap() { + usvg::LineCap::Butt => LineCap::Butt, + usvg::LineCap::Round => LineCap::Round, + usvg::LineCap::Square => LineCap::Square, + }, + line_join: match stroke.linejoin() { + usvg::LineJoin::Miter => LineJoin::Miter, + usvg::LineJoin::MiterClip => LineJoin::Miter, + usvg::LineJoin::Round => LineJoin::Round, + usvg::LineJoin::Bevel => LineJoin::Bevel, + }, + line_join_miter_limit: stroke.miterlimit().get() as f64, + transform, + }) } } -fn apply_usvg_fill(fill: Option<&usvg::Fill>, modify_inputs: &mut ModifyInputsContext, transform: DAffine2, bounds_transform: DAffine2) { - if let Some(fill) = &fill { - modify_inputs.fill_set(match &fill.paint() { - usvg::Paint::Color(color) => Fill::solid(usvg_color(*color, fill.opacity().get())), - usvg::Paint::LinearGradient(linear) => { - let local = [DVec2::new(linear.x1() as f64, linear.y1() as f64), DVec2::new(linear.x2() as f64, linear.y2() as f64)]; +fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, transform: DAffine2, bounds_transform: DAffine2) { + modify_inputs.fill_set(match &fill.paint() { + usvg::Paint::Color(color) => Fill::solid(usvg_color(*color, fill.opacity().get())), + usvg::Paint::LinearGradient(linear) => { + let local = [DVec2::new(linear.x1() as f64, linear.y1() as f64), DVec2::new(linear.x2() as f64, linear.y2() as f64)]; - // TODO: fix this - // let to_doc_transform = if linear.base.units() == usvg::Units::UserSpaceOnUse { - // transform - // } else { - // transformed_bound_transform - // }; - let to_doc_transform = transform; - let to_doc = to_doc_transform * usvg_transform(linear.transform()); + // TODO: fix this + // let to_doc_transform = if linear.base.units() == usvg::Units::UserSpaceOnUse { + // transform + // } else { + // transformed_bound_transform + // }; + let to_doc_transform = transform; + let to_doc = to_doc_transform * usvg_transform(linear.transform()); - let document = [to_doc.transform_point2(local[0]), to_doc.transform_point2(local[1])]; - let layer = [transform.inverse().transform_point2(document[0]), transform.inverse().transform_point2(document[1])]; + let document = [to_doc.transform_point2(local[0]), to_doc.transform_point2(local[1])]; + let layer = [transform.inverse().transform_point2(document[0]), transform.inverse().transform_point2(document[1])]; - let [start, end] = [bounds_transform.inverse().transform_point2(layer[0]), bounds_transform.inverse().transform_point2(layer[1])]; - let stops = linear.stops().iter().map(|stop| (stop.offset().get() as f64, usvg_color(stop.color(), stop.opacity().get()))).collect(); - let stops = GradientStops(stops); + let [start, end] = [bounds_transform.inverse().transform_point2(layer[0]), bounds_transform.inverse().transform_point2(layer[1])]; + let stops = linear.stops().iter().map(|stop| (stop.offset().get() as f64, usvg_color(stop.color(), stop.opacity().get()))).collect(); + let stops = GradientStops(stops); - Fill::Gradient(Gradient { - start, - end, - transform: DAffine2::IDENTITY, - gradient_type: GradientType::Linear, - stops, - }) - } - usvg::Paint::RadialGradient(radial) => { - let local = [DVec2::new(radial.cx() as f64, radial.cy() as f64), DVec2::new(radial.fx() as f64, radial.fy() as f64)]; + Fill::Gradient(Gradient { + start, + end, + transform: DAffine2::IDENTITY, + gradient_type: GradientType::Linear, + stops, + }) + } + usvg::Paint::RadialGradient(radial) => { + let local = [DVec2::new(radial.cx() as f64, radial.cy() as f64), DVec2::new(radial.fx() as f64, radial.fy() as f64)]; - // TODO: fix this - // let to_doc_transform = if radial.base.units == usvg::Units::UserSpaceOnUse { - // transform - // } else { - // transformed_bound_transform - // }; - let to_doc_transform = transform; - let to_doc = to_doc_transform * usvg_transform(radial.transform()); + // TODO: fix this + // let to_doc_transform = if radial.base.units == usvg::Units::UserSpaceOnUse { + // transform + // } else { + // transformed_bound_transform + // }; + let to_doc_transform = transform; + let to_doc = to_doc_transform * usvg_transform(radial.transform()); - let document = [to_doc.transform_point2(local[0]), to_doc.transform_point2(local[1])]; - let layer = [transform.inverse().transform_point2(document[0]), transform.inverse().transform_point2(document[1])]; + let document = [to_doc.transform_point2(local[0]), to_doc.transform_point2(local[1])]; + let layer = [transform.inverse().transform_point2(document[0]), transform.inverse().transform_point2(document[1])]; - let [start, end] = [bounds_transform.inverse().transform_point2(layer[0]), bounds_transform.inverse().transform_point2(layer[1])]; - let stops = radial.stops().iter().map(|stop| (stop.offset().get() as f64, usvg_color(stop.color(), stop.opacity().get()))).collect(); - let stops = GradientStops(stops); + let [start, end] = [bounds_transform.inverse().transform_point2(layer[0]), bounds_transform.inverse().transform_point2(layer[1])]; + let stops = radial.stops().iter().map(|stop| (stop.offset().get() as f64, usvg_color(stop.color(), stop.opacity().get()))).collect(); + let stops = GradientStops(stops); - Fill::Gradient(Gradient { - start, - end, - transform: DAffine2::IDENTITY, - gradient_type: GradientType::Radial, - stops, - }) - } - usvg::Paint::Pattern(_) => { - warn!("Skip pattern"); - return; - } - }); - } + Fill::Gradient(Gradient { + start, + end, + transform: DAffine2::IDENTITY, + gradient_type: GradientType::Radial, + stops, + }) + } + usvg::Paint::Pattern(_) => { + warn!("Skip pattern"); + return; + } + }); } diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 1fdb0859ce..870750b99f 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -145,32 +145,36 @@ impl<'a> ModifyInputsContext<'a> { self.network_interface.move_node_to_chain_start(&boolean_id, layer, &[]); } - pub fn insert_vector_data(&mut self, subpaths: Vec>, layer: LayerNodeIdentifier) { + pub fn insert_vector_data(&mut self, subpaths: Vec>, layer: LayerNodeIdentifier, include_transform: bool, include_fill: bool, include_stroke: bool) { let vector_data = VectorData::from_subpaths(subpaths, true); - let path = resolve_document_node_type("Path") + let shape = resolve_document_node_type("Path") .expect("Path node does not exist") .node_template_input_override([Some(NodeInput::value(TaggedValue::VectorData(vector_data), false))]); - - let transform = resolve_document_node_type("Transform").expect("Transform node does not exist").default_node_template(); - let fill = resolve_document_node_type("Fill").expect("Fill node does not exist").default_node_template(); - let stroke = resolve_document_node_type("Stroke").expect("Stroke node does not exist").default_node_template(); - let shape_id = NodeId(generate_uuid()); - self.network_interface.insert_node(shape_id, path, &[]); + self.network_interface.insert_node(shape_id, shape, &[]); self.network_interface.move_node_to_chain_start(&shape_id, layer, &[]); - let transform_id = NodeId(generate_uuid()); - self.network_interface.insert_node(transform_id, transform, &[]); - self.network_interface.move_node_to_chain_start(&transform_id, layer, &[]); + if include_transform { + let transform = resolve_document_node_type("Transform").expect("Transform node does not exist").default_node_template(); + let transform_id = NodeId(generate_uuid()); + self.network_interface.insert_node(transform_id, transform, &[]); + self.network_interface.move_node_to_chain_start(&transform_id, layer, &[]); + } - let fill_id = NodeId(generate_uuid()); - self.network_interface.insert_node(fill_id, fill, &[]); - self.network_interface.move_node_to_chain_start(&fill_id, layer, &[]); + if include_fill { + let fill = resolve_document_node_type("Fill").expect("Fill node does not exist").default_node_template(); + let fill_id = NodeId(generate_uuid()); + self.network_interface.insert_node(fill_id, fill, &[]); + self.network_interface.move_node_to_chain_start(&fill_id, layer, &[]); + } - let stroke_id = NodeId(generate_uuid()); - self.network_interface.insert_node(stroke_id, stroke, &[]); - self.network_interface.move_node_to_chain_start(&stroke_id, layer, &[]); + if include_stroke { + let stroke = resolve_document_node_type("Stroke").expect("Stroke node does not exist").default_node_template(); + let stroke_id = NodeId(generate_uuid()); + self.network_interface.insert_node(stroke_id, stroke, &[]); + self.network_interface.move_node_to_chain_start(&stroke_id, layer, &[]); + } } pub fn insert_text(&mut self, text: String, font: Font, size: f64, layer: LayerNodeIdentifier) { diff --git a/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs index 40a8547569..a88e426ef5 100644 --- a/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs @@ -103,7 +103,6 @@ impl LayoutHolder for MenuBarMessageHandler { label: "Import…".into(), shortcut: action_keys!(PortfolioMessageDiscriminant::Import), action: MenuBarEntry::create_action(|_| PortfolioMessage::Import.into()), - disabled: no_active_document, // TODO: Allow importing an image (or dragging it in, or pasting) without an active document to create a new one with an artboards of the image's size (issue #1140) ..MenuBarEntry::default() }, MenuBarEntry { diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 742894636a..02bb698a40 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -4,7 +4,9 @@ use crate::messages::frontend::utility_types::{ExportBounds, FileType}; use crate::messages::portfolio::document::utility_types::clipboards::Clipboard; use crate::messages::prelude::*; +use graphene_core::raster::Image; use graphene_core::text::Font; +use graphene_core::Color; #[impl_message(Message, Portfolio)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -87,6 +89,18 @@ pub enum PortfolioMessage { PasteSerializedData { data: String, }, + PasteImage { + name: Option, + image: Image, + mouse: Option<(f64, f64)>, + parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>, + }, + PasteSvg { + name: Option, + svg: String, + mouse: Option<(f64, f64)>, + parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>, + }, PrevDocument, SetActivePanel { panel: PanelType, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 2468c9ec98..b83fb90360 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -317,9 +317,7 @@ impl MessageHandler> for PortfolioMes } PortfolioMessage::Import => { // This portfolio message wraps the frontend message so it can be listed as an action, which isn't possible for frontend messages - if self.active_document().is_some() { - responses.add(FrontendMessage::TriggerImport); - } + responses.add(FrontendMessage::TriggerImport); } PortfolioMessage::LoadDocumentResources { document_id } => { if let Some(document) = self.document_mut(document_id) { @@ -636,6 +634,68 @@ impl MessageHandler> for PortfolioMes } } } + PortfolioMessage::PasteImage { + name, + image, + mouse, + parent_and_insert_index, + } => { + let create_document = self.documents.is_empty(); + + if create_document { + responses.add(PortfolioMessage::NewDocumentWithName { + name: name.clone().unwrap_or("Untitled Document".into()), + }); + } + + responses.add(DocumentMessage::PasteImage { + name, + image, + mouse, + parent_and_insert_index, + }); + + if create_document { + // Wait for the document to be rendered so the click targets can be calculated in order to determine the artboard size that will encompass the pasted image + responses.add(Message::StartBuffer); + responses.add(DocumentMessage::WrapContentInArtboard { place_artboard_at_origin: true }); + + // TODO: Figure out how to get StartBuffer to work here so we can delete this and use `DocumentMessage::ZoomCanvasToFitAll` instead + responses.add(Message::StartBuffer); + responses.add(FrontendMessage::TriggerDelayedZoomCanvasToFitAll); + } + } + PortfolioMessage::PasteSvg { + name, + svg, + mouse, + parent_and_insert_index, + } => { + let create_document = self.documents.is_empty(); + + if create_document { + responses.add(PortfolioMessage::NewDocumentWithName { + name: name.clone().unwrap_or("Untitled Document".into()), + }); + } + + responses.add(DocumentMessage::PasteSvg { + name, + svg, + mouse, + parent_and_insert_index, + }); + + if create_document { + // Wait for the document to be rendered so the click targets can be calculated in order to determine the artboard size that will encompass the pasted image + responses.add(Message::StartBuffer); + responses.add(DocumentMessage::WrapContentInArtboard { place_artboard_at_origin: true }); + + // TODO: Figure out how to get StartBuffer to work here so we can delete this and use `DocumentMessage::ZoomCanvasToFitAll` instead + responses.add(Message::StartBuffer); + responses.add(FrontendMessage::TriggerDelayedZoomCanvasToFitAll); + } + } PortfolioMessage::PrevDocument => { if let Some(active_document_id) = self.active_document_id { let len = self.document_ids.len(); diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index 6ec5f05074..69dbc4136c 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -36,7 +36,7 @@ pub fn new_image_layer(image_frame: ImageFrame, id: NodeId, parent: Layer /// Create a new group layer from an svg pub fn new_svg_layer(svg: String, transform: glam::DAffine2, id: NodeId, parent: LayerNodeIdentifier, responses: &mut VecDeque) -> LayerNodeIdentifier { let insert_index = 0; - responses.add(DocumentMessage::ImportSvg { + responses.add(GraphOperationMessage::NewSvg { id, svg, transform, diff --git a/editor/src/messages/tool/tool_messages/brush_tool.rs b/editor/src/messages/tool/tool_messages/brush_tool.rs index fffe3e280a..f2928c15b9 100644 --- a/editor/src/messages/tool/tool_messages/brush_tool.rs +++ b/editor/src/messages/tool/tool_messages/brush_tool.rs @@ -1,4 +1,5 @@ use super::tool_prelude::*; +use crate::consts::DEFAULT_BRUSH_SIZE; use crate::messages::portfolio::document::graph_operation::transform_utils::{get_current_normalized_pivot, get_current_transform}; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; @@ -41,7 +42,7 @@ pub struct BrushOptions { impl Default for BrushOptions { fn default() -> Self { Self { - diameter: 40., + diameter: DEFAULT_BRUSH_SIZE, hardness: 0., flow: 100., spacing: 20., diff --git a/editor/src/messages/tool/tool_messages/ellipse_tool.rs b/editor/src/messages/tool/tool_messages/ellipse_tool.rs index 4e06a23e4c..3e174cce4d 100644 --- a/editor/src/messages/tool/tool_messages/ellipse_tool.rs +++ b/editor/src/messages/tool/tool_messages/ellipse_tool.rs @@ -1,4 +1,5 @@ use super::tool_prelude::*; +use crate::consts::DEFAULT_STROKE_WIDTH; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; @@ -28,7 +29,7 @@ pub struct EllipseToolOptions { impl Default for EllipseToolOptions { fn default() -> Self { Self { - line_weight: 5., + line_weight: DEFAULT_STROKE_WIDTH, fill: ToolColorOptions::new_secondary(), stroke: ToolColorOptions::new_primary(), } diff --git a/editor/src/messages/tool/tool_messages/freehand_tool.rs b/editor/src/messages/tool/tool_messages/freehand_tool.rs index 6ea841cc94..110022bc22 100644 --- a/editor/src/messages/tool/tool_messages/freehand_tool.rs +++ b/editor/src/messages/tool/tool_messages/freehand_tool.rs @@ -1,4 +1,5 @@ use super::tool_prelude::*; +use crate::consts::DEFAULT_STROKE_WIDTH; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type; use crate::messages::portfolio::document::overlays::utility_functions::path_endpoint_overlays; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; @@ -31,7 +32,7 @@ pub struct FreehandOptions { impl Default for FreehandOptions { fn default() -> Self { Self { - line_weight: 5., + line_weight: DEFAULT_STROKE_WIDTH, fill: ToolColorOptions::new_none(), stroke: ToolColorOptions::new_primary(), } diff --git a/editor/src/messages/tool/tool_messages/line_tool.rs b/editor/src/messages/tool/tool_messages/line_tool.rs index 5808d62e49..e20751eb1b 100644 --- a/editor/src/messages/tool/tool_messages/line_tool.rs +++ b/editor/src/messages/tool/tool_messages/line_tool.rs @@ -1,5 +1,5 @@ use super::tool_prelude::*; -use crate::consts::LINE_ROTATE_SNAP_ANGLE; +use crate::consts::{DEFAULT_STROKE_WIDTH, LINE_ROTATE_SNAP_ANGLE}; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; @@ -28,7 +28,7 @@ pub struct LineOptions { impl Default for LineOptions { fn default() -> Self { Self { - line_weight: 5., + line_weight: DEFAULT_STROKE_WIDTH, stroke: ToolColorOptions::new_primary(), } } diff --git a/editor/src/messages/tool/tool_messages/pen_tool.rs b/editor/src/messages/tool/tool_messages/pen_tool.rs index 934c8a12d1..2ccd736dcb 100644 --- a/editor/src/messages/tool/tool_messages/pen_tool.rs +++ b/editor/src/messages/tool/tool_messages/pen_tool.rs @@ -1,6 +1,5 @@ use super::tool_prelude::*; -use crate::consts::HIDE_HANDLE_DISTANCE; -use crate::consts::LINE_ROTATE_SNAP_ANGLE; +use crate::consts::{DEFAULT_STROKE_WIDTH, HIDE_HANDLE_DISTANCE, LINE_ROTATE_SNAP_ANGLE}; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type; use crate::messages::portfolio::document::overlays::utility_functions::path_overlays; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; @@ -34,7 +33,7 @@ pub struct PenOptions { impl Default for PenOptions { fn default() -> Self { Self { - line_weight: 5., + line_weight: DEFAULT_STROKE_WIDTH, fill: ToolColorOptions::new_secondary(), stroke: ToolColorOptions::new_primary(), } diff --git a/editor/src/messages/tool/tool_messages/polygon_tool.rs b/editor/src/messages/tool/tool_messages/polygon_tool.rs index 66b415e947..0ddb4cddd2 100644 --- a/editor/src/messages/tool/tool_messages/polygon_tool.rs +++ b/editor/src/messages/tool/tool_messages/polygon_tool.rs @@ -1,4 +1,5 @@ use super::tool_prelude::*; +use crate::consts::DEFAULT_STROKE_WIDTH; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; @@ -33,7 +34,7 @@ impl Default for PolygonOptions { fn default() -> Self { Self { vertices: 5, - line_weight: 5., + line_weight: DEFAULT_STROKE_WIDTH, fill: ToolColorOptions::new_secondary(), stroke: ToolColorOptions::new_primary(), polygon_type: PolygonType::Convex, diff --git a/editor/src/messages/tool/tool_messages/rectangle_tool.rs b/editor/src/messages/tool/tool_messages/rectangle_tool.rs index 87b91cf955..f79f21fd28 100644 --- a/editor/src/messages/tool/tool_messages/rectangle_tool.rs +++ b/editor/src/messages/tool/tool_messages/rectangle_tool.rs @@ -1,4 +1,5 @@ use super::tool_prelude::*; +use crate::consts::DEFAULT_STROKE_WIDTH; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type; use crate::messages::portfolio::document::{graph_operation::utility_types::TransformIn, overlays::utility_types::OverlayContext}; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; @@ -27,7 +28,7 @@ pub struct RectangleToolOptions { impl Default for RectangleToolOptions { fn default() -> Self { Self { - line_weight: 5., + line_weight: DEFAULT_STROKE_WIDTH, fill: ToolColorOptions::new_secondary(), stroke: ToolColorOptions::new_primary(), } diff --git a/editor/src/messages/tool/tool_messages/spline_tool.rs b/editor/src/messages/tool/tool_messages/spline_tool.rs index ddf33b5396..e36dddfd32 100644 --- a/editor/src/messages/tool/tool_messages/spline_tool.rs +++ b/editor/src/messages/tool/tool_messages/spline_tool.rs @@ -1,5 +1,5 @@ use super::tool_prelude::*; -use crate::consts::DRAG_THRESHOLD; +use crate::consts::{DEFAULT_STROKE_WIDTH, DRAG_THRESHOLD}; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; @@ -27,7 +27,7 @@ pub struct SplineOptions { impl Default for SplineOptions { fn default() -> Self { Self { - line_weight: 5., + line_weight: DEFAULT_STROKE_WIDTH, fill: ToolColorOptions::new_none(), stroke: ToolColorOptions::new_primary(), } diff --git a/frontend/src/components/layout/LayoutCol.svelte b/frontend/src/components/layout/LayoutCol.svelte index 08165dcbbb..b69b6bfabb 100644 --- a/frontend/src/components/layout/LayoutCol.svelte +++ b/frontend/src/components/layout/LayoutCol.svelte @@ -42,6 +42,7 @@ on:dragleave on:dragover on:dragstart + on:drop on:mouseup on:pointerdown on:pointerenter @@ -58,7 +59,6 @@ on:copy on:cut on:drag on:dragenter -on:drop on:focus on:fullscreenchange on:fullscreenerror diff --git a/frontend/src/components/layout/LayoutRow.svelte b/frontend/src/components/layout/LayoutRow.svelte index ea622de02f..fad0220a76 100644 --- a/frontend/src/components/layout/LayoutRow.svelte +++ b/frontend/src/components/layout/LayoutRow.svelte @@ -42,6 +42,7 @@ on:dragleave on:dragover on:dragstart + on:drop on:mouseup on:pointerdown on:pointerenter @@ -58,7 +59,6 @@ on:copy on:cut on:drag on:dragenter -on:drop on:focus on:fullscreenchange on:fullscreenerror diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 1868aa9538..8faa6b3fd5 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -118,23 +118,33 @@ }; })($document.toolShelfLayout.layout[0]); - function pasteFile(e: DragEvent) { + function dropFile(e: DragEvent) { const { dataTransfer } = e; + const [x, y] = e.target instanceof Element && e.target.closest("[data-viewport]") ? [e.clientX, e.clientY] : [undefined, undefined]; if (!dataTransfer) return; + e.preventDefault(); Array.from(dataTransfer.items).forEach(async (item) => { const file = item.getAsFile(); - if (file?.type.includes("svg")) { - const svgData = await file.text(); - editor.handle.pasteSvg(svgData, e.clientX, e.clientY); + if (!file) return; + if (file.type.includes("svg")) { + const svgData = await file.text(); + editor.handle.pasteSvg(file.name, svgData, x, y); return; } - if (file?.type.startsWith("image")) { + if (file.type.startsWith("image")) { const imageData = await extractPixelData(file); - editor.handle.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height, e.clientX, e.clientY); + editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, x, y); + return; + } + + if (file.name.endsWith(".graphite")) { + const content = await file.text(); + editor.handle.openDocumentFile(file.name, content); + return; } }); } @@ -426,7 +436,7 @@ }); - + e.preventDefault()} on:drop={dropFile}> {#if !$document.graphViewOverlayOpen} @@ -482,7 +492,7 @@ y={cursorTop} /> {/if} -
canvasPointerDown(e)} on:dragover={(e) => e.preventDefault()} on:drop={(e) => pasteFile(e)} bind:this={viewport} data-viewport> +
canvasPointerDown(e)} bind:this={viewport} data-viewport> {@html artworkSvg} diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte index 3901b37567..ea6acd5bac 100644 --- a/frontend/src/components/panels/Layers.svelte +++ b/frontend/src/components/panels/Layers.svelte @@ -4,6 +4,7 @@ import { beginDraggingElement } from "@graphite/io-managers/drag"; import type { NodeGraphState } from "@graphite/state-providers/node-graph"; import { platformIsMac } from "@graphite/utility-functions/platform"; + import { extractPixelData } from "@graphite/utility-functions/rasterization"; import type { Editor } from "@graphite/wasm-communication/editor"; import { defaultWidgetLayout, patchWidgetLayout, UpdateDocumentLayerDetails, UpdateDocumentLayerStructureJs, UpdateLayersPanelOptionsLayout } from "@graphite/wasm-communication/messages"; import type { DataBuffer, LayerPanelEntry } from "@graphite/wasm-communication/messages"; @@ -305,6 +306,8 @@ } function updateInsertLine(event: DragEvent) { + if (!draggable) return; + // Stop the drag from being shown as cancelled event.preventDefault(); dragInPanel = true; @@ -312,13 +315,48 @@ if (list) draggingData = calculateDragIndex(list, event.clientY, draggingData?.select); } - async function drop() { - if (draggingData && dragInPanel) { - const { select, insertParentId, insertIndex } = draggingData; + function drop(e: DragEvent) { + if (!draggingData) return; + const { select, insertParentId, insertIndex } = draggingData; + + e.preventDefault(); + + if (e.dataTransfer) { + // Moving layers + if (e.dataTransfer.items.length === 0) { + if (draggable && dragInPanel) { + select?.(); + editor.handle.moveLayerInTree(insertParentId, insertIndex); + } + } + // Importing files + else { + Array.from(e.dataTransfer.items).forEach(async (item) => { + const file = item.getAsFile(); + if (!file) return; + + if (file.type.includes("svg")) { + const svgData = await file.text(); + editor.handle.pasteSvg(file.name, svgData, undefined, undefined, insertParentId, insertIndex); + return; + } + + if (file.type.startsWith("image")) { + const imageData = await extractPixelData(file); + editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, undefined, undefined, insertParentId, insertIndex); + return; + } - select?.(); - editor.handle.moveLayerInTree(insertParentId, insertIndex); + // When we eventually have sub-documents, this should be changed to import the document instead of opening it in a separate tab + if (file.name.endsWith(".graphite")) { + const content = await file.text(); + editor.handle.openDocumentFile(file.name, content); + return; + } + }); + } } + draggingData = undefined; fakeHighlight = undefined; dragInPanel = false; @@ -369,7 +407,7 @@ - deselectAllLayers()} on:dragover={(e) => draggable && updateInsertLine(e)} on:dragend={() => draggable && drop()}> + deselectAllLayers()} on:dragover={updateInsertLine} on:dragend={drop} on:drop={drop}> {#each layers as listing, index} { + const file = item.getAsFile(); + if (!file) return; + + if (file.type.includes("svg")) { + const svgData = await file.text(); + editor.handle.pasteSvg(file.name, svgData); + return; + } + + if (file.type.startsWith("image")) { + const imageData = await extractPixelData(file); + editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height); + return; + } + + if (file.name.endsWith(".graphite")) { + const content = await file.text(); + editor.handle.openDocumentFile(file.name, content); + return; + } + }); + } + export async function scrollTabIntoView(newIndex: number) { await tick(); tabElements[newIndex]?.div?.()?.scrollIntoView(); @@ -76,7 +106,7 @@ } }} on:mouseup={(e) => { - // Fallback for Safari: + // Middle mouse button click fallback for Safari: // https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event#browser_compatibility // The downside of using mouseup is that the mousedown didn't have to originate in the same element. // A possible future improvement could save the target element during mousedown and check if it's the same here. @@ -110,7 +140,7 @@ {#if panelType} {:else} - + e.preventDefault()} on:drop={dropFile}> diff --git a/frontend/src/io-managers/drag.ts b/frontend/src/io-managers/drag.ts index 7f713c0ec3..4d4264331c 100644 --- a/frontend/src/io-managers/drag.ts +++ b/frontend/src/io-managers/drag.ts @@ -11,9 +11,7 @@ export function createDragManager(): () => void { // Return the destructor return () => { // We use setTimeout to sequence this drop after any potential users in the current call stack progression, since this will begin in an entirely new call stack later - setTimeout(() => { - document.removeEventListener("drop", clearDraggingElement); - }, 0); + setTimeout(() => document.removeEventListener("drop", clearDraggingElement), 0); }; } diff --git a/frontend/src/io-managers/input.ts b/frontend/src/io-managers/input.ts index 7e2dff6efc..5dbcaaa9c8 100644 --- a/frontend/src/io-managers/input.ts +++ b/frontend/src/io-managers/input.ts @@ -283,17 +283,21 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli } const file = item.getAsFile(); + if (!file) return; - if (file?.type === "svg") { + if (file.type.includes("svg")) { const text = await file.text(); - editor.handle.pasteSvg(text); - + editor.handle.pasteSvg(file.name, text); return; } - if (file?.type.startsWith("image")) { + if (file.type.startsWith("image")) { const imageData = await extractPixelData(file); - editor.handle.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height); + editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height); + } + + if (file.name.endsWith(".graphite")) { + editor.handle.openDocumentFile(file.name, await file.text()); } }); } @@ -316,52 +320,63 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli if (!clipboardItems) throw new Error("Clipboard API unsupported"); // Read any layer data or images from the clipboard - Array.from(clipboardItems).forEach(async (item) => { - // Read plain text and, if it is a layer, pass it to the editor - if (item.types.includes("text/plain")) { - const blob = await item.getType("text/plain"); - const reader = new FileReader(); - reader.onload = () => { - const text = reader.result as string; - - if (text.startsWith("graphite/layer: ")) { - editor.handle.pasteSerializedData(text.substring(16, text.length)); - } - }; - reader.readAsText(blob); - } - - // Read an image from the clipboard and pass it to the editor to be loaded - const imageType = item.types.find((type) => type.startsWith("image/")); - - if (imageType === "svg") { - const blob = await item.getType("text/plain"); - const reader = new FileReader(); - reader.onload = () => { - const text = reader.result as string; - editor.handle.pasteSvg(text); - }; - reader.readAsText(blob); - - return; - } - - if (imageType) { - const blob = await item.getType(imageType); - const reader = new FileReader(); - reader.onload = async () => { - if (reader.result instanceof ArrayBuffer) { - const imageData = await extractPixelData(new Blob([reader.result], { type: imageType })); - editor.handle.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height); - } - }; - reader.readAsArrayBuffer(blob); - } - }); + const success = await Promise.any( + Array.from(clipboardItems).map(async (item) => { + // Read plain text and, if it is a layer, pass it to the editor + if (item.types.includes("text/plain")) { + const blob = await item.getType("text/plain"); + const reader = new FileReader(); + reader.onload = () => { + const text = reader.result as string; + + if (text.startsWith("graphite/layer: ")) { + editor.handle.pasteSerializedData(text.substring(16, text.length)); + } + }; + reader.readAsText(blob); + return true; + } + + // Read an image from the clipboard and pass it to the editor to be loaded + const imageType = item.types.find((type) => type.startsWith("image/")); + + // Import the actual SVG content if it's an SVG + if (imageType?.includes("svg")) { + const blob = await item.getType("text/plain"); + const reader = new FileReader(); + reader.onload = () => { + const text = reader.result as string; + editor.handle.pasteSvg(undefined, text); + }; + reader.readAsText(blob); + return true; + } + + // Import the bitmap image if it's an image + if (imageType) { + const blob = await item.getType(imageType); + const reader = new FileReader(); + reader.onload = async () => { + if (reader.result instanceof ArrayBuffer) { + const imageData = await extractPixelData(new Blob([reader.result], { type: imageType })); + editor.handle.pasteImage(undefined, new Uint8Array(imageData.data), imageData.width, imageData.height); + } + }; + reader.readAsArrayBuffer(blob); + return true; + } + + // The API limits what kinds of data we can access, so we can get copied images and our text encodings of copied nodes, but not files (like + // .graphite or even image files). However, the user can paste those with Ctrl+V, which we recommend they in the error message that's shown to them. + return false; + }), + ); + + if (!success) throw new Error("No valid clipboard data"); } catch (err) { const unsupported = stripIndents` This browser does not support reading from the clipboard. - Use the keyboard shortcut to paste instead. + Use the standard keyboard shortcut to paste instead. `; const denied = stripIndents` The browser's clipboard permission has been denied. @@ -369,11 +384,16 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli Open the browser's website settings (usually accessible just left of the URL) to allow this permission. `; + const nothing = stripIndents` + No valid clipboard data was found. You may have better + luck pasting with the standard keyboard shortcut instead. + `; const matchMessage = { "clipboard-read": unsupported, "Clipboard API unsupported": unsupported, "Permission denied": denied, + "No valid clipboard data": nothing, }; const message = Object.entries(matchMessage).find(([key]) => String(err).includes(key))?.[1] || String(err); diff --git a/frontend/src/state-providers/document.ts b/frontend/src/state-providers/document.ts index f5d4682820..2fe94185bb 100644 --- a/frontend/src/state-providers/document.ts +++ b/frontend/src/state-providers/document.ts @@ -92,11 +92,9 @@ export function createDocumentState(editor: Editor) { }); editor.subscriptions.subscribeJsMessage(TriggerDelayedZoomCanvasToFitAll, () => { // TODO: This is horribly hacky - setTimeout(() => editor.handle.zoomCanvasToFitAll(), 0); - setTimeout(() => editor.handle.zoomCanvasToFitAll(), 1); - setTimeout(() => editor.handle.zoomCanvasToFitAll(), 10); - setTimeout(() => editor.handle.zoomCanvasToFitAll(), 50); - setTimeout(() => editor.handle.zoomCanvasToFitAll(), 100); + [0, 1, 10, 50, 100, 200, 300, 400, 500].forEach((delay) => { + setTimeout(() => editor.handle.zoomCanvasToFitAll(), delay); + }); }); return { diff --git a/frontend/src/state-providers/portfolio.ts b/frontend/src/state-providers/portfolio.ts index c441fb694f..a80342b5c5 100644 --- a/frontend/src/state-providers/portfolio.ts +++ b/frontend/src/state-providers/portfolio.ts @@ -65,17 +65,22 @@ export function createPortfolioState(editor: Editor) { editor.handle.openDocumentFile(data.filename, data.content); }); editor.subscriptions.subscribeJsMessage(TriggerImport, async () => { - const data = await upload("image/*", "data"); + const data = await upload("image/*", "both"); if (data.type.includes("svg")) { - const svg = new TextDecoder().decode(data.content); - editor.handle.pasteSvg(svg); + const svg = new TextDecoder().decode(data.content.data); + editor.handle.pasteSvg(data.filename, svg); + return; + } + // In case the user accidentally uploads a Graphite file, open it instead of failing to import it + if (data.filename.endsWith(".graphite")) { + editor.handle.openDocumentFile(data.filename, data.content.text); return; } - const imageData = await extractPixelData(new Blob([data.content], { type: data.type })); - editor.handle.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height); + const imageData = await extractPixelData(new Blob([data.content.data], { type: data.type })); + editor.handle.pasteImage(data.filename, new Uint8Array(imageData.data), imageData.width, imageData.height); }); editor.subscriptions.subscribeJsMessage(TriggerDownloadTextFile, (triggerFileDownload) => { downloadFileText(triggerFileDownload.name, triggerFileDownload.document); diff --git a/frontend/src/utility-functions/files.ts b/frontend/src/utility-functions/files.ts index fb22e7a0a2..a9d30f6736 100644 --- a/frontend/src/utility-functions/files.ts +++ b/frontend/src/utility-functions/files.ts @@ -22,7 +22,7 @@ export function downloadFileText(filename: string, text: string) { downloadFileBlob(filename, blob); } -export async function upload(acceptedExtensions: string, textOrData: T): Promise> { +export async function upload(acceptedExtensions: string, textOrData: T): Promise> { return new Promise>((resolve, _) => { const element = document.createElement("input"); element.type = "file"; @@ -36,7 +36,15 @@ export async function upload(acceptedExtensions: stri const filename = file.name; const type = file.type; - const content = (textOrData === "text" ? await file.text() : new Uint8Array(await file.arrayBuffer())) as UploadResultType; + const content = ( + textOrData === "text" + ? await file.text() + : textOrData === "data" + ? new Uint8Array(await file.arrayBuffer()) + : textOrData === "both" + ? { text: await file.text(), data: new Uint8Array(await file.arrayBuffer()) } + : undefined + ) as UploadResultType; resolve({ filename, type, content }); } @@ -50,7 +58,7 @@ export async function upload(acceptedExtensions: stri }); } export type UploadResult = { filename: string; type: string; content: UploadResultType }; -type UploadResultType = T extends "text" ? string : T extends "data" ? Uint8Array : never; +type UploadResultType = T extends "text" ? string : T extends "data" ? Uint8Array : T extends "both" ? { text: string; data: Uint8Array } : never; export function blobToBase64(blob: Blob): Promise { return new Promise((resolve) => { diff --git a/frontend/src/wasm-communication/messages.ts b/frontend/src/wasm-communication/messages.ts index afe81ac821..7839a0d62f 100644 --- a/frontend/src/wasm-communication/messages.ts +++ b/frontend/src/wasm-communication/messages.ts @@ -129,7 +129,8 @@ export abstract class DocumentDetails { readonly isSaved!: boolean; - readonly id!: bigint | string; + // This field must be provided by the subclass implementation + // readonly id!: bigint | string; get displayName(): string { return `${this.name}${this.isSaved ? "" : "*"}`; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c3f3f196c5..3a5b77e237 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "ES2020", - "module": "esnext", + "target": "ESNext", + "module": "ESNext", "strict": true, "importHelpers": true, "moduleResolution": "node", @@ -18,7 +18,7 @@ "@graphite-frontend/*": ["./*"], "@graphite/*": ["src/*"] }, - "lib": ["esnext", "dom", "dom.iterable", "scripthost"] + "lib": ["ESNext", "DOM", "DOM.Iterable", "ScriptHost"] }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.svelte", "*.ts", "*.js", "*.cjs"], "exclude": ["node_modules"], diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index b6baad82bc..4345c9092b 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -593,17 +593,55 @@ impl EditorHandle { /// Pastes an image #[wasm_bindgen(js_name = pasteImage)] - pub fn paste_image(&self, image_data: Vec, width: u32, height: u32, mouse_x: Option, mouse_y: Option) { + pub fn paste_image( + &self, + name: Option, + image_data: Vec, + width: u32, + height: u32, + mouse_x: Option, + mouse_y: Option, + insert_parent_id: Option, + insert_index: Option, + ) { let mouse = mouse_x.and_then(|x| mouse_y.map(|y| (x, y))); let image = graphene_core::raster::Image::from_image_data(&image_data, width, height); - let message = DocumentMessage::PasteImage { image, mouse }; + + let parent_and_insert_index = if let (Some(insert_parent_id), Some(insert_index)) = (insert_parent_id, insert_index) { + let insert_parent_id = NodeId(insert_parent_id); + let parent = LayerNodeIdentifier::new_unchecked(insert_parent_id); + Some((parent, insert_index)) + } else { + None + }; + + let message = PortfolioMessage::PasteImage { + name, + image, + mouse, + parent_and_insert_index, + }; self.dispatch(message); } #[wasm_bindgen(js_name = pasteSvg)] - pub fn paste_svg(&self, svg: String, mouse_x: Option, mouse_y: Option) { + pub fn paste_svg(&self, name: Option, svg: String, mouse_x: Option, mouse_y: Option, insert_parent_id: Option, insert_index: Option) { let mouse = mouse_x.and_then(|x| mouse_y.map(|y| (x, y))); - let message = DocumentMessage::PasteSvg { svg, mouse }; + + let parent_and_insert_index = if let (Some(insert_parent_id), Some(insert_index)) = (insert_parent_id, insert_index) { + let insert_parent_id = NodeId(insert_parent_id); + let parent = LayerNodeIdentifier::new_unchecked(insert_parent_id); + Some((parent, insert_index)) + } else { + None + }; + + let message = PortfolioMessage::PasteSvg { + name, + svg, + mouse, + parent_and_insert_index, + }; self.dispatch(message); } diff --git a/node-graph/gcore/src/vector/vector_nodes.rs b/node-graph/gcore/src/vector/vector_nodes.rs index 31a68019c1..cf08168291 100644 --- a/node-graph/gcore/src/vector/vector_nodes.rs +++ b/node-graph/gcore/src/vector/vector_nodes.rs @@ -141,7 +141,7 @@ async fn stroke> + 'n + Send>( )] #[default(Color::BLACK)] color: T, - #[default(5.)] weight: f64, + #[default(2.)] weight: f64, dash_lengths: Vec, dash_offset: f64, line_cap: crate::vector::style::LineCap, diff --git a/website/other/bezier-rs-demos/tsconfig.json b/website/other/bezier-rs-demos/tsconfig.json index 297b6a7e31..4d4d94dd71 100644 --- a/website/other/bezier-rs-demos/tsconfig.json +++ b/website/other/bezier-rs-demos/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "ES2020", - "module": "esnext", + "target": "ESNext", + "module": "ESNext", "strict": true, "importHelpers": true, "moduleResolution": "node", @@ -14,7 +14,7 @@ "paths": { "@/*": ["src/*"] }, - "lib": ["esnext", "dom", "dom.iterable", "scripthost"] + "lib": ["ESNext", "DOM", "DOM.Iterable", "ScriptHost"] }, "include": ["src/**/*.ts", "src/**/*.d.ts", "*.ts", "*.js", "*.cjs"], "exclude": ["node_modules"],