Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions desktop/wrapper/src/intercept_frontend_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
17 changes: 10 additions & 7 deletions editor/src/messages/clipboard/clipboard_message_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,22 @@ impl MessageHandler<ClipboardMessage, ClipboardMessageContext<'_>> 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 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This changes layer copy from a synchronous TriggerClipboardWrite into a runtime request whose generated frontend message is discarded by EditorTestUtils::handle_message. As a result, the existing clipboard copy tests fail at their .expect; propagate the asynchronous clipboard response through the test/runtime API or preserve a directly observable write result.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At editor/src/messages/clipboard/clipboard_message_handler.rs, line 93:

<comment>This changes layer copy from a synchronous `TriggerClipboardWrite` into a runtime request whose generated frontend message is discarded by `EditorTestUtils::handle_message`. As a result, the existing clipboard copy tests fail at their `.expect`; propagate the asynchronous clipboard response through the test/runtime API or preserve a directly observable write result.</comment>

<file context>
@@ -80,19 +80,22 @@ impl MessageHandler<ClipboardMessage, ClipboardMessageContext<'_>> for Clipboard
-				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) => {
</file context>

}
ClipboardContent::Text(text) => {
responses.add(FrontendMessage::TriggerClipboardWrite { content: text });
}
}
}

ClipboardMessage::CopyLayers => {
Expand Down
4 changes: 4 additions & 0 deletions editor/src/messages/frontend/frontend_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ pub enum FrontendMessage {
TriggerClipboardWrite {
content: String,
},
TriggerClipboardSvgWrite {
svg_string: String,
graphite_json: String,
},
TriggerSelectionRead {
cut: bool,
},
Expand Down
3 changes: 3 additions & 0 deletions editor/src/messages/portfolio/portfolio_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ pub enum PortfolioMessage {
/// New sizes for the children at that split node.
sizes: Vec<f64>,
},
RequestSvgTextCopy {
graphite_json: String,
},
}

/// Clone helper for the non-serializable `gdd` payload: a cloned mount message carries no `Gdd`.
Expand Down
8 changes: 8 additions & 0 deletions editor/src/messages/portfolio/portfolio_message_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1689,6 +1689,14 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout);
}
}
PortfolioMessage::RequestSvgTextCopy { graphite_json } => {
if let Some(active_document) = self.active_document() {
let selected_nodes: Vec<NodeId> = 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());
}
}
}
}

Expand Down
10 changes: 10 additions & 0 deletions editor/src/node_graph_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ pub enum NodeGraphUpdate {
CompilationResponse(CompilationResponse),
EyedropperPreview(Raster<CPU>),
NodeGraphUpdateMessage(NodeGraphUpdateMessage),
SvgTextCopyClipboard(String, String),
}

#[derive(Debug, Default)]
Expand Down Expand Up @@ -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 });
}
}
}

Expand Down Expand Up @@ -812,6 +816,12 @@ impl NodeGraphExecutor {

Ok(())
}

pub fn copy_svg_clipboard(&self, graphite_json: String, selected_nodes: Vec<NodeId>) {
self.runtime_io
.send(GraphRuntimeRequest::CopySvgTextClipboard(graphite_json, selected_nodes))
.expect("Failed to send runtime request");
Comment on lines +820 to +823

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: copy_svg_clipboard panics the whole application on send failure via .expect(...). Match the codebase's preferred non-panicking style for this new public entry point: handle the send result with a log (e.g., if let Err(e) = self.runtime_io.send(...) { log::error!(...) }) instead of asserting, so a runtime-IO failure doesn't crash the editor.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At editor/src/node_graph_executor.rs, line 820:

<comment>`copy_svg_clipboard` panics the whole application on send failure via `.expect(...)`. Match the codebase's preferred non-panicking style for this new public entry point: handle the send result with a log (e.g., `if let Err(e) = self.runtime_io.send(...) { log::error!(...) }`) instead of asserting, so a runtime-IO failure doesn't crash the editor.</comment>

<file context>
@@ -812,6 +816,12 @@ impl NodeGraphExecutor {
 		Ok(())
 	}
+
+	pub fn copy_svg_clipboard(&self, graphite_json: String, selected_nodes: Vec<NodeId>) {
+		self.runtime_io
+			.send(GraphRuntimeRequest::CopySvgTextClipboard(graphite_json, selected_nodes))
</file context>
Suggested change
pub fn copy_svg_clipboard(&self, graphite_json: String, selected_nodes: Vec<NodeId>) {
self.runtime_io
.send(GraphRuntimeRequest::CopySvgTextClipboard(graphite_json, selected_nodes))
.expect("Failed to send runtime request");
pub fn copy_svg_clipboard(&self, graphite_json: String, selected_nodes: Vec<NodeId>) {
if let Err(error) = self.runtime_io.send(GraphRuntimeRequest::CopySvgTextClipboard(graphite_json, selected_nodes)) {
log::error!("Failed to send runtime request: {error:?}");
}
}

}
}

// TODO: Eventually remove this document upgrade code
Expand Down
62 changes: 61 additions & 1 deletion editor/src/node_graph_executor/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ pub enum GraphRuntimeRequest {
GraphUpdate(GraphUpdate),
ExecutionRequest(ExecutionRequest),
EditorPreferencesUpdate(EditorPreferences),
CopySvgTextClipboard(String, Vec<NodeId>),
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
Expand Down Expand Up @@ -108,6 +109,10 @@ impl InternalNodeGraphUpdateSender {
fn send_eyedropper_preview(&self, raster: Raster<CPU>) {
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 {
Expand Down Expand Up @@ -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),
Expand All @@ -182,6 +188,7 @@ impl NodeRuntime {
}
}
GraphRuntimeRequest::EditorPreferencesUpdate(_) => preferences = Some(request),
GraphRuntimeRequest::CopySvgTextClipboard(..) => svg_clipboard = Some(request),
}
}

Expand All @@ -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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a CopySvgTextClipboard request is batched with an ExecutionRequest in the same run() pass, the copy is silently dropped. The ExecutionRequest arm ends with return texture;, and svg_clipboard is ordered after execution in the requests array, so the early return exits before the copy is processed. Since run() drains all queued requests each frame, a copy issued while a viewport render is queued loses the clipboard write with no response sent. Process svg_clipboard before the execution arm's early return (e.g., place it earlier in the array), or handle it before the return.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At editor/src/node_graph_executor/runtime.rs, line 203:

<comment>When a `CopySvgTextClipboard` request is batched with an `ExecutionRequest` in the same `run()` pass, the copy is silently dropped. The `ExecutionRequest` arm ends with `return texture;`, and `svg_clipboard` is ordered after `execution` in the requests array, so the early return exits before the copy is processed. Since `run()` drains all queued requests each frame, a copy issued while a viewport render is queued loses the clipboard write with no response sent. Process `svg_clipboard` before the execution arm's early return (e.g., place it earlier in the array), or handle it before the `return`.</comment>

<file context>
@@ -193,7 +200,7 @@ impl NodeRuntime {
 		}
 
-		let requests = [preferences, graph, eyedropper, execution].into_iter().flatten();
+		let requests = [preferences, graph, eyedropper, execution, svg_clipboard].into_iter().flatten();
 
 		for request in requests {
</file context>
Suggested change
let requests = [preferences, graph, eyedropper, execution, svg_clipboard].into_iter().flatten();
let requests = [preferences, graph, svg_clipboard, eyedropper, execution].into_iter().flatten();


for request in requests {
match request {
Expand Down Expand Up @@ -340,6 +347,59 @@ impl NodeRuntime {
});
return texture;
}
GraphRuntimeRequest::CopySvgTextClipboard(text_string_clipboard, selected_node_ids) => {
let mut combined_graphics = List::<Graphic>::new();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the selection contains an artboard, this branch ignores its List<Artboard> monitor output and sends an empty SVG. Render artboard outputs as SVG too, preserving their location, dimensions, and clipping.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At editor/src/node_graph_executor/runtime.rs, line 351:

<comment>When the selection contains an artboard, this branch ignores its `List<Artboard>` monitor output and sends an empty SVG. Render artboard outputs as SVG too, preserving their location, dimensions, and clipping.</comment>

<file context>
@@ -340,6 +347,59 @@ impl NodeRuntime {
 					return texture;
 				}
+				GraphRuntimeRequest::CopySvgTextClipboard(text_string_clipboard, selected_node_ids) => {
+					let mut combined_graphics = List::<Graphic>::new();
+
+					for monitor_node_path in &self.monitor_nodes {
</file context>


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::<IORecord<Context, List<Graphic>>>() {
combined_graphics.extend(io.output.clone());
} else if let Some(io) = introspected_data.downcast_ref::<IORecord<Context, Item<Graphic>>>() {
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
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/managers/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,28 @@ 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")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: On browsers where the ClipboardItem interface exists but the static supports() method is unavailable (e.g. Safari before 18.4, which shipped ClipboardItem in 13.1 but supports() only in 18.4), ClipboardItem.supports(...) throws a TypeError before the else fallback runs, so even the writeText fallback never executes and the copy fails entirely. Guard the feature detection (e.g., ClipboardItem.supports?.(...) or wrap in try/catch, as the linked MDN example does) so non-supporting browsers fall back to writeText.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/src/managers/clipboard.ts, line 29:

<comment>On browsers where the `ClipboardItem` interface exists but the static `supports()` method is unavailable (e.g. Safari before 18.4, which shipped `ClipboardItem` in 13.1 but `supports()` only in 18.4), `ClipboardItem.supports(...)` throws a TypeError before the `else` fallback runs, so even the `writeText` fallback never executes and the copy fails entirely. Guard the feature detection (e.g., `ClipboardItem.supports?.(...)` or wrap in try/catch, as the linked MDN example does) so non-supporting browsers fall back to `writeText`.</comment>

<file context>
@@ -23,13 +23,28 @@ export function createClipboardManager(subscriptions: SubscriptionsRouter, edito
+
+	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({
</file context>

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("TriggerClipboardSvgWrite");
subscriptions.unsubscribeFrontendMessage("TriggerSelectionRead");
subscriptions.unsubscribeFrontendMessage("TriggerSelectionWrite");
}
Expand Down