diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 6d13e4bc4c..c215507767 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -119,6 +119,9 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD FrontendMessage::TriggerClipboardWrite { content } => { dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { content }); } + FrontendMessage::TriggerClipboardSvgWrite { graphite_json, .. } => { + 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 73b72b7d6e..c8ba8544cd 100644 --- a/editor/src/messages/clipboard/clipboard_message_handler.rs +++ b/editor/src/messages/clipboard/clipboard_message_handler.rs @@ -80,19 +80,22 @@ impl MessageHandler> for Clipboard } } ClipboardMessage::Write { content } => { - let text = match content { + match content { ClipboardContent::Svg(_) => { log::error!("SVG copying is not yet supported"); - return; + // Need to fix this. } 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 }); + ClipboardContent::Graphite(graphite) => { + let graphite_json = format!("{CLIPBOARD_PREFIX}{graphite}"); + responses.add(PortfolioMessage::RequestSvgTextCopy { graphite_json }); + } + ClipboardContent::Text(text) => { + responses.add(FrontendMessage::TriggerClipboardWrite { content: text }); + } + } } ClipboardMessage::CopyLayers => { diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 29daaa269b..1336320e49 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -153,6 +153,10 @@ pub enum FrontendMessage { TriggerClipboardWrite { content: String, }, + TriggerClipboardSvgWrite { + svg_string: String, + graphite_json: String, + }, TriggerSelectionRead { cut: bool, }, diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 8edca5950d..6378508a78 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 847662e4f0..3acb95b161 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/node_graph_executor.rs b/editor/src/node_graph_executor.rs index a7a9abf6eb..f55d5090f4 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(String, String), } #[derive(Debug, Default)] @@ -466,6 +467,9 @@ impl NodeGraphExecutor { responses.add(EyedropperToolMessage::PreviewImage { data, width, height }); } NodeGraphUpdate::NodeGraphUpdateMessage(_) => {} + NodeGraphUpdate::SvgTextCopyClipboard(svg_string, graphite_json) => { + responses.add(FrontendMessage::TriggerClipboardSvgWrite { svg_string, graphite_json }); + } } } @@ -812,6 +816,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 8ce1ccea57..cf32b9d8e0 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, text_string: String) { + self.0.send(NodeGraphUpdate::SvgTextCopyClipboard(svg_string, text_string)).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, execution, svg_clipboard].into_iter().flatten(); for request in requests { match request { @@ -340,6 +347,59 @@ impl NodeRuntime { }); return texture; } + GraphRuntimeRequest::CopySvgTextClipboard(text_string_clipboard, selected_node_ids) => { + let mut combined_graphics = List::::new(); + + for monitor_node_path in &self.monitor_nodes { + // Skip inspect monitor node if active + if self.inspect_state.as_ref().is_some_and(|state| monitor_node_path.last().copied() == Some(state.monitor_node)) { + 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) { + if let Some(io) = introspected_data.downcast_ref::>>() { + combined_graphics.extend(io.output.clone()); + } else if let Some(io) = introspected_data.downcast_ref::>>() { + combined_graphics.push(io.output.clone()); + } + } + } + } + + 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 raw_bounds = match bounds { + RenderBoundingBox::Rectangle(bounds) if (bounds[1] - bounds[0]) != DVec2::ZERO => bounds, + _ => [DVec2::ZERO, DVec2::ONE], + }; + + let footprint = Footprint { + transform: DAffine2::from_translation(DVec2::new(raw_bounds[0].x, raw_bounds[0].y)), + resolution: UVec2::new((raw_bounds[1].x - raw_bounds[0].x).abs().ceil() as u32, (raw_bounds[1].y - raw_bounds[0].y).abs().ceil() as u32).max(UVec2::ONE), + quality: RenderQuality::Full, + }; + + let render_params = RenderParams { + footprint, + thumbnail: false, + ..Default::default() + }; + let mut render = SvgRender::new(); + combined_graphics.render_svg(&mut render, &render_params); + render.format_svg(raw_bounds[0], raw_bounds[1]); + + self.sender.send_svg_text_clipboard(render.svg.to_svg_string(), text_string_clipboard); + } } } None diff --git a/frontend/src/managers/clipboard.ts b/frontend/src/managers/clipboard.ts index 54dcda5eb2..60ccecb145 100644 --- a/frontend/src/managers/clipboard.ts +++ b/frontend/src/managers/clipboard.ts @@ -23,6 +23,20 @@ export function createClipboardManager(subscriptions: SubscriptionsRouter, edito subscriptions.subscribeFrontendMessage("TriggerSelectionWrite", async (data) => { insertAtCaret(data.content); }); + + subscriptions.subscribeFrontendMessage("TriggerClipboardSvgWrite", (data) => { + // Adopted from https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem#browser_compatibility + if (ClipboardItem.supports("image/svg+xml")) { + 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() { @@ -30,6 +44,7 @@ export function destroyClipboardManager() { if (!subscriptions) return; subscriptions.unsubscribeFrontendMessage("TriggerClipboardWrite"); + subscriptions.unsubscribeFrontendMessage("TriggerClipboardSvgWrite"); subscriptions.unsubscribeFrontendMessage("TriggerSelectionRead"); subscriptions.unsubscribeFrontendMessage("TriggerSelectionWrite"); }