diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 6d13e4bc4c6..6cce36a2000 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -116,8 +116,9 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD FrontendMessage::TriggerClipboardRead => { dispatcher.respond(DesktopFrontendMessage::ClipboardRead); } - FrontendMessage::TriggerClipboardWrite { content } => { - dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { content }); + FrontendMessage::TriggerClipboardSvgAndJsonWrite { graphite_json, .. } => { + // TODO: Add support for svg after clipboard API change in desktop. + dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { content: graphite_json }); } FrontendMessage::WindowPointerLock => { dispatcher.respond(DesktopFrontendMessage::PointerLock); diff --git a/editor/src/messages/clipboard/clipboard_message_handler.rs b/editor/src/messages/clipboard/clipboard_message_handler.rs index 73b72b7d6e8..a36d08daaa9 100644 --- a/editor/src/messages/clipboard/clipboard_message_handler.rs +++ b/editor/src/messages/clipboard/clipboard_message_handler.rs @@ -79,21 +79,18 @@ impl MessageHandler> for Clipboard responses.add(ClipboardMessage::CopyLayers); } } - ClipboardMessage::Write { content } => { - let text = match content { - ClipboardContent::Svg(_) => { - log::error!("SVG copying is not yet supported"); - return; - } - ClipboardContent::Image { .. } => { - log::error!("Image copying is not yet supported"); - return; - } - ClipboardContent::Graphite(graphite) => format!("{CLIPBOARD_PREFIX}{graphite}"), - ClipboardContent::Text(text) => text, - }; - responses.add(FrontendMessage::TriggerClipboardWrite { content: text }); - } + ClipboardMessage::Write { content } => match content { + ClipboardContent::Image { .. } => { + log::error!("Image copying is not yet supported"); + } + ClipboardContent::Graphite(graphite) => { + let graphite_json = format!("{CLIPBOARD_PREFIX}{graphite}"); + responses.add(PortfolioMessage::RequestSvgTextCopy { graphite_json }); + } + ClipboardContent::Text(graphite_json) => { + responses.add(FrontendMessage::TriggerClipboardSvgAndJsonWrite { svg_string: None, graphite_json }); + } + }, ClipboardMessage::CopyLayers => { if current_tool == &ToolType::Path { @@ -526,7 +523,7 @@ mod test { .await .into_iter() .find_map(|message| match message { - FrontendMessage::TriggerClipboardWrite { content } => Some(content), + FrontendMessage::TriggerClipboardSvgAndJsonWrite { graphite_json, .. } => Some(graphite_json), _ => None, }) .expect("copying layers should write a payload to the clipboard") diff --git a/editor/src/messages/clipboard/utility_types.rs b/editor/src/messages/clipboard/utility_types.rs index a4298222ca6..f47c14bc5f5 100644 --- a/editor/src/messages/clipboard/utility_types.rs +++ b/editor/src/messages/clipboard/utility_types.rs @@ -18,7 +18,6 @@ pub enum ClipboardContentRaw { pub enum ClipboardContent { Graphite(String), Text(String), - Svg(String), Image { data: Vec, width: u32, height: u32 }, } diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 29daaa269b9..85462efc7f4 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -150,8 +150,9 @@ pub enum FrontendMessage { url: String, }, TriggerClipboardRead, - TriggerClipboardWrite { - content: String, + TriggerClipboardSvgAndJsonWrite { + svg_string: Option, + graphite_json: String, }, TriggerSelectionRead { cut: bool, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 4925fa070d8..674cef08261 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -4333,7 +4333,7 @@ mod document_message_handler_tests { }) .await; - let instrumented = editor.eval_graph().await.unwrap(); + let (instrumented, _) = editor.eval_graph().await.unwrap(); // The emptiness guards keep these assertions honest: a wrong `Output` type on `grab_all_input` yields no records at all, which would otherwise pass without checking anything let base_lengths: Vec = instrumented diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 50a11fb0340..f29c108a12c 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -126,7 +126,7 @@ mod network_interface_tests { let clipboard = frontend_messages .into_iter() .find_map(|msg| match msg { - FrontendMessage::TriggerClipboardWrite { content } => Some(content), + FrontendMessage::TriggerClipboardSvgAndJsonWrite { graphite_json, .. } => Some(graphite_json), _ => None, }) .expect("copy message should be dispatched"); diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 8edca5950d3..6378508a78e 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -219,6 +219,9 @@ pub enum PortfolioMessage { /// New sizes for the children at that split node. sizes: Vec, }, + RequestSvgTextCopy { + graphite_json: String, + }, } /// Clone helper for the non-serializable `gdd` payload: a cloned mount message carries no `Gdd`. diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 847662e4f0f..3acb95b161d 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -1689,6 +1689,14 @@ impl MessageHandler> for Portfolio responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout); } } + PortfolioMessage::RequestSvgTextCopy { graphite_json } => { + if let Some(active_document) = self.active_document() { + let selected_nodes: Vec = active_document.network_interface.shallowest_unique_layers(&[]).map(|layer| layer.to_node()).collect(); + self.executor.copy_svg_clipboard(graphite_json, selected_nodes); + } else { + self.executor.copy_svg_clipboard(graphite_json, Vec::new()); + } + } } } diff --git a/editor/src/messages/tool/common_functionality/shapes/ellipse_shape.rs b/editor/src/messages/tool/common_functionality/shapes/ellipse_shape.rs index 5bc4745c879..945fd4a703a 100644 --- a/editor/src/messages/tool/common_functionality/shapes/ellipse_shape.rs +++ b/editor/src/messages/tool/common_functionality/shapes/ellipse_shape.rs @@ -64,7 +64,7 @@ mod test_ellipse { async fn get_ellipse(editor: &mut EditorTestUtils) -> Vec { let instrumented = match editor.eval_graph().await { - Ok(instrumented) => instrumented, + Ok((instrumented, _)) => instrumented, Err(e) => panic!("Failed to evaluate graph: {e}"), }; diff --git a/editor/src/messages/tool/tool_messages/artboard_tool.rs b/editor/src/messages/tool/tool_messages/artboard_tool.rs index 5ba52683c55..73451bba4b9 100644 --- a/editor/src/messages/tool/tool_messages/artboard_tool.rs +++ b/editor/src/messages/tool/tool_messages/artboard_tool.rs @@ -575,7 +575,7 @@ mod test_artboard { use graphene_std::list::List; async fn get_artboards(editor: &mut EditorTestUtils) -> List { - let instrumented = match editor.eval_graph().await { + let (instrumented, _) = match editor.eval_graph().await { Ok(instrumented) => instrumented, Err(e) => panic!("Failed to evaluate graph: {e}"), }; diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index e0755f0d6c8..272ad1c1255 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -269,7 +269,7 @@ mod test_fill { // The Fill tool writes solid colors, whose stored values the input monitor records as `Item` wires async fn get_fills(editor: &mut EditorTestUtils) -> Vec> { - let instrumented = match editor.eval_graph().await { + let (instrumented, _) = match editor.eval_graph().await { Ok(instrumented) => instrumented, Err(e) => panic!("Failed to evaluate graph: {e}"), }; diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index a7a9abf6ebf..011a29b28df 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -52,6 +52,7 @@ pub enum NodeGraphUpdate { CompilationResponse(CompilationResponse), EyedropperPreview(Raster), NodeGraphUpdateMessage(NodeGraphUpdateMessage), + SvgTextCopyClipboard { svg_string: String, graphite_json: String }, } #[derive(Debug, Default)] @@ -466,6 +467,12 @@ impl NodeGraphExecutor { responses.add(EyedropperToolMessage::PreviewImage { data, width, height }); } NodeGraphUpdate::NodeGraphUpdateMessage(_) => {} + NodeGraphUpdate::SvgTextCopyClipboard { svg_string, graphite_json } => { + responses.add(FrontendMessage::TriggerClipboardSvgAndJsonWrite { + svg_string: Some(svg_string), + graphite_json, + }); + } } } @@ -812,6 +819,12 @@ impl NodeGraphExecutor { Ok(()) } + + pub fn copy_svg_clipboard(&self, graphite_json: String, selected_nodes: Vec) { + self.runtime_io + .send(GraphRuntimeRequest::CopySvgTextClipboard(graphite_json, selected_nodes)) + .expect("Failed to send runtime request"); + } } // TODO: Eventually remove this document upgrade code diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index 8ce1ccea575..e88fa4f8366 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -70,6 +70,7 @@ pub enum GraphRuntimeRequest { GraphUpdate(GraphUpdate), ExecutionRequest(ExecutionRequest), EditorPreferencesUpdate(EditorPreferences), + CopySvgTextClipboard(String, Vec), } #[derive(Debug, serde::Serialize, serde::Deserialize)] @@ -108,6 +109,10 @@ impl InternalNodeGraphUpdateSender { fn send_eyedropper_preview(&self, raster: Raster) { self.0.send(NodeGraphUpdate::EyedropperPreview(raster)).expect("Failed to send response") } + + fn send_svg_text_clipboard(&self, svg_string: String, graphite_json: String) { + self.0.send(NodeGraphUpdate::SvgTextCopyClipboard { svg_string, graphite_json }).expect("Failed to send response") + } } impl NodeGraphUpdateSender for InternalNodeGraphUpdateSender { @@ -162,6 +167,7 @@ impl NodeRuntime { let mut graph = None; let mut eyedropper = None; let mut execution = None; + let mut svg_clipboard = None; for request in self.receiver.try_iter() { match request { GraphRuntimeRequest::GraphUpdate(_) => graph = Some(request), @@ -182,6 +188,7 @@ impl NodeRuntime { } } GraphRuntimeRequest::EditorPreferencesUpdate(_) => preferences = Some(request), + GraphRuntimeRequest::CopySvgTextClipboard(..) => svg_clipboard = Some(request), } } @@ -193,7 +200,7 @@ impl NodeRuntime { eyedropper.render_config.pointer = execution.render_config.pointer; } - let requests = [preferences, graph, eyedropper, execution].into_iter().flatten(); + let requests = [preferences, graph, eyedropper, svg_clipboard, execution].into_iter().flatten(); for request in requests { match request { @@ -340,6 +347,28 @@ impl NodeRuntime { }); return texture; } + GraphRuntimeRequest::CopySvgTextClipboard(text_string_clipboard, selected_node_ids) => { + let combined_graphics = self.collect_graphics(&selected_node_ids); + + if combined_graphics.is_empty() { + self.sender.send_svg_text_clipboard(String::new(), text_string_clipboard); + return None; + } + + let bounds = graphene_std::renderer::graphic_list_bounding_box(&combined_graphics, DAffine2::IDENTITY); + let final_bounds = match bounds { + RenderBoundingBox::Rectangle(bounds) if (bounds[1] - bounds[0]) != DVec2::ZERO => bounds, + _ => [DVec2::ZERO, DVec2::ONE], + }; + + let footprint = Footprint::from_bounds(final_bounds, RenderQuality::Full); + let render_params = RenderParams { footprint, ..Default::default() }; + let mut render = SvgRender::new(); + combined_graphics.render_svg(&mut render, &render_params); + render.format_svg(final_bounds[0], final_bounds[1]); + + self.sender.send_svg_text_clipboard(render.svg.to_svg_string(), text_string_clipboard); + } } } None @@ -392,11 +421,7 @@ impl NodeRuntime { for monitor_node_path in &self.monitor_nodes { // Skip the inspect monitor node - if self - .inspect_state - .as_ref() - .is_some_and(|inspect_state| monitor_node_path.last().copied() == Some(inspect_state.monitor_node)) - { + if self.is_insepect_monitor_node(monitor_node_path) { continue; } @@ -498,11 +523,7 @@ impl NodeRuntime { }; let bounds = expand_to_thumbnail_aspect(raw_bounds); let new_thumbnail_svg = { - let footprint = Footprint { - transform: DAffine2::from_translation(DVec2::new(bounds[0].x, bounds[0].y)), - resolution: UVec2::new((bounds[1].x - bounds[0].x).abs() as u32, (bounds[1].y - bounds[0].y).abs() as u32), - quality: RenderQuality::Full, - }; + let footprint = Footprint::from_bounds(bounds, RenderQuality::Full); // Render the thumbnail from a `Graphic` into an SVG string let render_params = RenderParams { @@ -529,6 +550,36 @@ impl NodeRuntime { *old_thumbnail_svg = new_thumbnail_svg; } } + + fn collect_graphics(&self, selected_node_ids: &Vec) -> List { + let mut combined_graphics = List::::new(); + for monitor_node_path in &self.monitor_nodes { + // Skip inspect monitor node if active + if self.is_insepect_monitor_node(monitor_node_path) { + continue; + } + + let Some(parent_network_node_id) = monitor_node_path.len().checked_sub(2).and_then(|index| monitor_node_path.get(index)).copied() else { + continue; + }; + + if selected_node_ids.contains(&parent_network_node_id) { + // Introspect using the full monitor node path + if let Ok(introspected_data) = self.executor.introspect(monitor_node_path) + && let Some(io) = introspected_data.downcast_ref::>>() + { + combined_graphics.extend(io.output.clone()); + } else { + warn!("No graphic type is matched while extracting svg"); + } + } + } + combined_graphics + } + + fn is_insepect_monitor_node(&self, monitor_node_path: &Vec) -> bool { + self.inspect_state.as_ref().is_some_and(|state| monitor_node_path.last().copied() == Some(state.monitor_node)) + } } /// Returns the union of the artboards' clipping rectangles, used as the thumbnail bounds for an artboard layer so the diff --git a/editor/src/test_utils.rs b/editor/src/test_utils.rs index 57c567b82c8..118bf2f83ae 100644 --- a/editor/src/test_utils.rs +++ b/editor/src/test_utils.rs @@ -33,9 +33,9 @@ impl EditorTestUtils { Self { editor, runtime } } - pub fn eval_graph<'a>(&'a mut self) -> impl std::future::Future> + 'a { + pub fn eval_graph<'a>(&'a mut self) -> impl std::future::Future), String>> + 'a { // An inner function is required since async functions in traits are a bit weird - async fn run<'a>(editor: &'a mut Editor, runtime: &'a mut NodeRuntime) -> Result { + async fn run<'a>(editor: &'a mut Editor, runtime: &'a mut NodeRuntime) -> Result<(Instrumented, Vec), String> { let portfolio = &mut editor.dispatcher.message_handlers.portfolio_message_handler; let document_id = portfolio.active_document_id.unwrap(); let (executor, documents) = (&mut portfolio.executor, &mut portfolio.documents); @@ -55,24 +55,25 @@ impl EditorTestUtils { if let Err(e) = editor.poll_node_graph_evaluation(&mut messages) { return Err(format!("Graph should render\n\n{e}")); } - let frontend_messages = messages.into_iter().flat_map(|message| editor.handle_message(message)); + let frontend_messages = messages.into_iter().flat_map(|message| editor.handle_message(message)).collect::>(); - for message in frontend_messages { + for message in &frontend_messages { message.check_node_graph_error(); } - Ok(instrumented) + Ok((instrumented, frontend_messages)) } run(&mut self.editor, &mut self.runtime) } pub async fn handle_message(&mut self, message: impl Into) -> Vec { - let frontend_messages_from_msg = self.editor.handle_message(message); + let mut frontend_messages_from_msg = self.editor.handle_message(message); // Required to process any buffered messages - if let Err(e) = self.eval_graph().await { - panic!("Failed to evaluate graph: {e}"); + match self.eval_graph().await { + Ok((_, new_messages)) => frontend_messages_from_msg.extend(new_messages), + Err(e) => panic!("Failed to evaluate graph: {e}"), } // Sweep the network interface's structural invariants so any desync fails at the message that caused it diff --git a/frontend/src/managers/clipboard.ts b/frontend/src/managers/clipboard.ts index 54dcda5eb2e..831fdd89f9f 100644 --- a/frontend/src/managers/clipboard.ts +++ b/frontend/src/managers/clipboard.ts @@ -11,11 +11,6 @@ export function createClipboardManager(subscriptions: SubscriptionsRouter, edito subscriptionsRouter = subscriptions; editorWrapper = editor; - subscriptions.subscribeFrontendMessage("TriggerClipboardWrite", (data) => { - // If the Clipboard API is supported in the browser, copy text to the clipboard - navigator.clipboard?.writeText?.(data.content); - }); - subscriptions.subscribeFrontendMessage("TriggerSelectionRead", async (data) => { editor.readSelection(readAtCaret(data.cut), data.cut); }); @@ -23,13 +18,27 @@ export function createClipboardManager(subscriptions: SubscriptionsRouter, edito subscriptions.subscribeFrontendMessage("TriggerSelectionWrite", async (data) => { insertAtCaret(data.content); }); + + subscriptions.subscribeFrontendMessage("TriggerClipboardSvgAndJsonWrite", (data) => { + // Adopted from https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem#browser_compatibility + if (ClipboardItem.supports("image/svg+xml") && data.svg_string !== undefined) { + navigator.clipboard?.write?.([ + new ClipboardItem({ + "image/svg+xml": data.svg_string, + "text/plain": data.graphite_json, + }), + ]); + } else { + navigator.clipboard?.writeText?.(data.graphite_json); + } + }); } export function destroyClipboardManager() { const subscriptions = subscriptionsRouter; if (!subscriptions) return; - subscriptions.unsubscribeFrontendMessage("TriggerClipboardWrite"); + subscriptions.unsubscribeFrontendMessage("TriggerClipboardSvgAndJsonWrite"); subscriptions.unsubscribeFrontendMessage("TriggerSelectionRead"); subscriptions.unsubscribeFrontendMessage("TriggerSelectionWrite"); } diff --git a/node-graph/libraries/core-types/src/transform.rs b/node-graph/libraries/core-types/src/transform.rs index 7f2aa272a62..a6c6ce31130 100644 --- a/node-graph/libraries/core-types/src/transform.rs +++ b/node-graph/libraries/core-types/src/transform.rs @@ -189,6 +189,14 @@ impl Footprint { quality: RenderQuality::Full, }; + pub fn from_bounds(bounds: [DVec2; 2], quality: RenderQuality) -> Self { + Footprint { + transform: DAffine2::from_translation(DVec2::new(bounds[0].x, bounds[0].y)), + resolution: UVec2::new((bounds[1].x - bounds[0].x).abs().ceil() as u32, (bounds[1].y - bounds[0].y).abs().ceil() as u32).max(UVec2::ONE), + quality, + } + } + pub fn viewport_bounds_in_local_space(&self) -> AxisAlignedBbox { let inverse = self.transform.inverse(); let res = self.resolution.as_dvec2();