diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 1f48b438b00..b7283ef7b72 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -342,8 +342,8 @@ impl App { DesktopFrontendMessage::ClipboardRead => { self.app_event_scheduler.schedule(AppEvent::ClipboardRead); } - DesktopFrontendMessage::ClipboardWrite { content } => { - self.app_event_scheduler.schedule(AppEvent::ClipboardWrite { content }); + DesktopFrontendMessage::ClipboardWrite { svg_string, graphite_json } => { + self.app_event_scheduler.schedule(AppEvent::ClipboardWrite { svg_string, graphite_json }); } DesktopFrontendMessage::PointerLock => { self.input_state.lock_pointer(); @@ -484,10 +484,12 @@ impl App { } } } - AppEvent::ClipboardWrite { content } => { - let send_data = DataTransferSendBuilder::new(content) - .with_type(TypeHint::Plaintext, |content: &String, _| Some(content.clone())) - .build(); + AppEvent::ClipboardWrite { svg_string, graphite_json } => { + let mut builder = DataTransferSendBuilder::new((svg_string.clone(), graphite_json)).with_type(TypeHint::Plaintext, |(_, graphite_json), _| Some(graphite_json.clone())); + if svg_string.is_some() { + builder = builder.with_type(TypeHint::Image { extension_hint: Some("svg") }, |(svg, _), _| svg.as_ref().map(|x| x.as_bytes().to_vec())); + } + let send_data = builder.build(); if let Err(e) = event_loop.set_clipboard(send_data) { tracing::error!("Failed to write to clipboard: {e}"); } diff --git a/desktop/src/event.rs b/desktop/src/event.rs index cbe0f77f3b4..c126c55df4e 100644 --- a/desktop/src/event.rs +++ b/desktop/src/event.rs @@ -10,7 +10,8 @@ pub(crate) enum AppEvent { NodeGraphExecutionResult(NodeGraphExecutionResult), ClipboardRead, ClipboardWrite { - content: String, + svg_string: Option, + graphite_json: String, }, Exit, UiCrashed, diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 6d13e4bc4c6..15c9fb412f7 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -116,8 +116,8 @@ 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 { svg_string, graphite_json } => { + dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { svg_string, graphite_json }); } FrontendMessage::WindowPointerLock => { dispatcher.respond(DesktopFrontendMessage::PointerLock); diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index 0559e02f36a..cb547b39db6 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -67,7 +67,8 @@ pub enum DesktopFrontendMessage { }, ClipboardRead, ClipboardWrite { - content: String, + svg_string: Option, + graphite_json: String, }, PointerLock, WindowClose, 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 8347239f0a9..c814a4544fc 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -4331,7 +4331,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..25c52f8c3ea 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: Option, graphite_json: 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::TriggerClipboardSvgAndJsonWrite { 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 8ce1ccea575..c2b452d149f 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: Option, 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,8 +200,8 @@ 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(); + let mut return_texture = None; for request in requests { match request { GraphRuntimeRequest::EditorPreferencesUpdate(preferences) => { @@ -210,139 +217,169 @@ impl NodeRuntime { let _ = self.update_network(graph).await; } } - GraphRuntimeRequest::GraphUpdate(GraphUpdate { - mut network, - resources, - node_to_inspect, - }) => { - // Insert the monitor node to manage the inspection - self.inspect_state = InspectState::monitor_inspect_node(&mut network, &node_to_inspect); + GraphRuntimeRequest::GraphUpdate(graph_update) => self.graph_update(graph_update).await, + GraphRuntimeRequest::ExecutionRequest(request) => self.execution_request(request, &mut return_texture).await, + GraphRuntimeRequest::CopySvgTextClipboard(text_string_clipboard, selected_node_ids) => self.copy_svg_text_clipboard(text_string_clipboard, selected_node_ids), + } + } + return_texture + } - self.old_graph = Some(network.clone()); - self.resources = resources; + async fn graph_update(&mut self, graph_update: GraphUpdate) { + let GraphUpdate { + mut network, + resources, + node_to_inspect, + } = graph_update; - self.node_graph_errors.clear(); - let result = self.update_network(network).await; - let node_graph_errors = self.node_graph_errors.clone(); + // Insert the monitor node to manage the inspection + self.inspect_state = InspectState::monitor_inspect_node(&mut network, &node_to_inspect); - self.update_thumbnails = true; + self.old_graph = Some(network.clone()); + self.resources = resources; - self.sender.send_compilation_response(CompilationResponse { result, node_graph_errors }); - } - GraphRuntimeRequest::ExecutionRequest(ExecutionRequest { execution_id, mut render_config, .. }) => { - // We may want to render via the SVG pipeline even though raster was requested, if SVG Preview render mode is active or WebGPU/Vello is unavailable - if render_config.export_format == ExportFormat::Raster - && (render_config.render_mode == RenderMode::SvgPreview || self.editor_api.application_io.as_ref().unwrap().gpu_executor().is_none()) - { - render_config.export_format = ExportFormat::Svg; - } + self.node_graph_errors.clear(); + let result = self.update_network(network).await; + let node_graph_errors = self.node_graph_errors.clone(); - let result = self.execute_network(render_config).await; - let mut responses = VecDeque::new(); - // TODO: Only process monitor nodes if the graph has changed, not when only the Footprint changes - if !render_config.for_eyedropper { - self.process_monitor_nodes(&mut responses, self.update_thumbnails); - } - self.update_thumbnails = false; - - // Resolve the result from the inspection by accessing the monitor node - let inspect_result = self.inspect_state.as_ref().and_then(|state| state.access(&self.executor)); - - let (result, texture) = match result { - Ok(TaggedValue::RenderOutput(RenderOutput { - data: RenderOutputType::Texture(texture), - metadata, - })) if render_config.for_export => { - let executor = self - .editor_api - .application_io - .as_ref() - .unwrap() - .gpu_executor() - .expect("GPU executor should be available when we receive a texture"); - - let raster_cpu = Raster::new_gpu(texture).convert(Footprint::BOUNDLESS, executor).await; - - let (data, width, height) = raster_cpu.to_flat_u8(); - - ( - Ok(TaggedValue::RenderOutput(RenderOutput { - data: RenderOutputType::Buffer { data, width, height }, - metadata, - })), - None, - ) - } - Ok(TaggedValue::RenderOutput(RenderOutput { - data: RenderOutputType::Texture(texture), - metadata: _, - })) if render_config.for_eyedropper => { - let executor = self - .editor_api - .application_io - .as_ref() - .unwrap() - .gpu_executor() - .expect("GPU executor should be available when we receive a texture"); - - let raster_cpu = Raster::new_gpu(texture).convert(Footprint::BOUNDLESS, executor).await; - - self.sender.send_eyedropper_preview(raster_cpu); - continue; - } - // Eyedropper render that didn't produce a texture (e.g., SVG fallback when GPU is unavailable); discard it - _ if render_config.for_eyedropper => { - continue; - } - #[cfg(all(target_family = "wasm", feature = "gpu"))] - Ok(TaggedValue::RenderOutput(RenderOutput { - data: RenderOutputType::Texture(texture), - metadata, - })) if !render_config.for_export => { - self.current_viewport_texture = Some(texture.clone()); - - let app_io = self.editor_api.application_io.as_ref().unwrap(); - let executor = app_io.gpu_executor().expect("GPU executor should be available when we receive a texture"); - - self.wasm_canvas_cache.present(&texture, executor); - - let logical_resolution = render_config.viewport.resolution.as_dvec2() / render_config.scale; - ( - Ok(TaggedValue::RenderOutput(RenderOutput { - data: RenderOutputType::CanvasFrame { - canvas_id: self.wasm_canvas_cache.id(), - resolution: logical_resolution, - }, - metadata, - })), - None, - ) - } - Ok(TaggedValue::RenderOutput(RenderOutput { - data: RenderOutputType::Texture(texture), - metadata, - })) => ( - Ok(TaggedValue::RenderOutput(RenderOutput { - data: RenderOutputType::Texture(texture.clone()), - metadata, - })), - Some(texture), - ), - r => (r, None), - }; - - self.sender.send_execution_response(ExecutionResponse { - execution_id, - result, - responses, - vector_modify: self.vector_modify.clone(), - inspect_result, - }); - return texture; - } + self.update_thumbnails = true; + + self.sender.send_compilation_response(CompilationResponse { result, node_graph_errors }); + } + + async fn execution_request(&mut self, request: ExecutionRequest, return_texture: &mut Option) { + let ExecutionRequest { execution_id, mut render_config, .. } = request; + + // We may want to render via the SVG pipeline even though raster was requested, if SVG Preview render mode is active or WebGPU/Vello is unavailable + if render_config.export_format == ExportFormat::Raster && (render_config.render_mode == RenderMode::SvgPreview || self.editor_api.application_io.as_ref().unwrap().gpu_executor().is_none()) { + render_config.export_format = ExportFormat::Svg; + } + + let result = self.execute_network(render_config).await; + let mut responses = VecDeque::new(); + // TODO: Only process monitor nodes if the graph has changed, not when only the Footprint changes + if !render_config.for_eyedropper { + self.process_monitor_nodes(&mut responses, self.update_thumbnails); + } + self.update_thumbnails = false; + + // Resolve the result from the inspection by accessing the monitor node + let inspect_result = self.inspect_state.as_ref().and_then(|state| state.access(&self.executor)); + + let (result, texture) = match result { + Ok(TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::Texture(texture), + metadata, + })) if render_config.for_export => { + let executor = self + .editor_api + .application_io + .as_ref() + .unwrap() + .gpu_executor() + .expect("GPU executor should be available when we receive a texture"); + + let raster_cpu = Raster::new_gpu(texture).convert(Footprint::BOUNDLESS, executor).await; + + let (data, width, height) = raster_cpu.to_flat_u8(); + + ( + Ok(TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::Buffer { data, width, height }, + metadata, + })), + None, + ) + } + Ok(TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::Texture(texture), + metadata: _, + })) if render_config.for_eyedropper => { + let executor = self + .editor_api + .application_io + .as_ref() + .unwrap() + .gpu_executor() + .expect("GPU executor should be available when we receive a texture"); + + let raster_cpu = Raster::new_gpu(texture).convert(Footprint::BOUNDLESS, executor).await; + + self.sender.send_eyedropper_preview(raster_cpu); + return; + } + // Eyedropper render that didn't produce a texture (e.g., SVG fallback when GPU is unavailable); discard it + _ if render_config.for_eyedropper => { + return; } + #[cfg(all(target_family = "wasm", feature = "gpu"))] + Ok(TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::Texture(texture), + metadata, + })) if !render_config.for_export => { + self.current_viewport_texture = Some(texture.clone()); + + let app_io = self.editor_api.application_io.as_ref().unwrap(); + let executor = app_io.gpu_executor().expect("GPU executor should be available when we receive a texture"); + + self.wasm_canvas_cache.present(&texture, executor); + + let logical_resolution = render_config.viewport.resolution.as_dvec2() / render_config.scale; + ( + Ok(TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::CanvasFrame { + canvas_id: self.wasm_canvas_cache.id(), + resolution: logical_resolution, + }, + metadata, + })), + None, + ) + } + Ok(TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::Texture(texture), + metadata, + })) => ( + Ok(TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::Texture(texture.clone()), + metadata, + })), + Some(texture), + ), + r => (r, None), + }; + + self.sender.send_execution_response(ExecutionResponse { + execution_id, + result, + responses, + vector_modify: self.vector_modify.clone(), + inspect_result, + }); + *return_texture = texture; + } + + fn copy_svg_text_clipboard(&self, text_string_clipboard: String, selected_node_ids: Vec) { + let combined_graphics = self.collect_graphics(&selected_node_ids); + + if combined_graphics.is_empty() { + self.sender.send_svg_text_clipboard(None, 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(Some(render.svg.to_svg_string()), text_string_clipboard); } async fn update_network(&mut self, graph: NodeNetwork) -> Result { @@ -392,11 +429,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 +531,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 +558,36 @@ impl NodeRuntime { *old_thumbnail_svg = new_thumbnail_svg; } } + + fn collect_graphics(&self, selected_node_ids: &[NodeId]) -> 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: &[NodeId]) -> 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..8dfc66764d5 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(bounds[0].min(bounds[1])), + resolution: (bounds[1] - bounds[0]).abs().ceil().as_uvec2().max(UVec2::ONE), + quality, + } + } + pub fn viewport_bounds_in_local_space(&self) -> AxisAlignedBbox { let inverse = self.transform.inverse(); let res = self.resolution.as_dvec2();