Desktop: Text clipboard support (#3461)

* gray background for viewport texture

* cust copy paste support

* connect clipboard read on web

* fix eyedropper bounds

* cleanup

* add missing char events for some named keys like enter
This commit is contained in:
Timon
2025-12-12 00:16:35 +00:00
committed by GitHub
parent d6c06da878
commit 6d852f11af
30 changed files with 602 additions and 115 deletions
+3
View File
@@ -18,6 +18,7 @@ pub struct DispatcherMessageHandlers {
animation_message_handler: AnimationMessageHandler,
app_window_message_handler: AppWindowMessageHandler,
broadcast_message_handler: BroadcastMessageHandler,
clipboard_message_handler: ClipboardMessageHandler,
debug_message_handler: DebugMessageHandler,
defer_message_handler: DeferMessageHandler,
dialog_message_handler: DialogMessageHandler,
@@ -158,6 +159,7 @@ impl Dispatcher {
self.message_handlers.app_window_message_handler.process_message(message, &mut queue, ());
}
Message::Broadcast(message) => self.message_handlers.broadcast_message_handler.process_message(message, &mut queue, ()),
Message::Clipboard(message) => self.message_handlers.clipboard_message_handler.process_message(message, &mut queue, ()),
Message::Debug(message) => {
self.message_handlers.debug_message_handler.process_message(message, &mut queue, ());
}
@@ -319,6 +321,7 @@ impl Dispatcher {
// TODO: Reduce the number of heap allocations
let mut list = Vec::new();
list.extend(self.message_handlers.app_window_message_handler.actions());
list.extend(self.message_handlers.clipboard_message_handler.actions());
list.extend(self.message_handlers.dialog_message_handler.actions());
list.extend(self.message_handlers.animation_message_handler.actions());
list.extend(self.message_handlers.input_preprocessor_message_handler.actions());
@@ -0,0 +1,13 @@
use crate::messages::clipboard::utility_types::{ClipboardContent, ClipboardContentRaw};
use crate::messages::prelude::*;
#[impl_message(Message, Clipboard)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum ClipboardMessage {
Cut,
Copy,
Paste,
ReadClipboard { content: ClipboardContentRaw },
ReadSelection { content: Option<String>, cut: bool },
Write { content: ClipboardContent },
}
@@ -0,0 +1,85 @@
use crate::messages::clipboard::utility_types::{ClipboardContent, ClipboardContentRaw};
use crate::messages::prelude::*;
use graphene_std::raster::Image;
use graphite_proc_macros::{ExtractField, message_handler_data};
const CLIPBOARD_PREFIX_LAYER: &str = "graphite/layer: ";
const CLIPBOARD_PREFIX_NODES: &str = "graphite/nodes: ";
const CLIPBOARD_PREFIX_VECTOR: &str = "graphite/vector: ";
#[derive(Debug, Clone, Default, ExtractField)]
pub struct ClipboardMessageHandler {}
#[message_handler_data]
impl MessageHandler<ClipboardMessage, ()> for ClipboardMessageHandler {
fn process_message(&mut self, message: ClipboardMessage, responses: &mut std::collections::VecDeque<Message>, _: ()) {
match message {
ClipboardMessage::Cut => responses.add(FrontendMessage::TriggerSelectionRead { cut: true }),
ClipboardMessage::Copy => responses.add(FrontendMessage::TriggerSelectionRead { cut: false }),
ClipboardMessage::Paste => responses.add(FrontendMessage::TriggerClipboardRead),
ClipboardMessage::ReadClipboard { content } => match content {
ClipboardContentRaw::Text(text) => {
if let Some(layer) = text.strip_prefix(CLIPBOARD_PREFIX_LAYER) {
responses.add(PortfolioMessage::PasteSerializedData { data: layer.to_string() });
} else if let Some(nodes) = text.strip_prefix(CLIPBOARD_PREFIX_NODES) {
responses.add(NodeGraphMessage::PasteNodes { serialized_nodes: nodes.to_string() });
} else if let Some(vector) = text.strip_prefix(CLIPBOARD_PREFIX_VECTOR) {
responses.add(PortfolioMessage::PasteSerializedVector { data: vector.to_string() });
} else {
responses.add(FrontendMessage::TriggerSelectionWrite { content: text });
}
}
ClipboardContentRaw::Svg(svg) => {
responses.add(PortfolioMessage::PasteSvg {
svg,
name: None,
mouse: None,
parent_and_insert_index: None,
});
}
ClipboardContentRaw::Image { data, width, height } => {
responses.add(PortfolioMessage::PasteImage {
image: Image::from_image_data(&data, width, height),
name: None,
mouse: None,
parent_and_insert_index: None,
});
}
},
ClipboardMessage::ReadSelection { content, cut } => {
use crate::messages::portfolio::document::utility_types::clipboards::Clipboard;
if let Some(text) = content {
responses.add(ClipboardMessage::Write {
content: ClipboardContent::Text(text),
});
} else if cut {
responses.add(PortfolioMessage::Cut { clipboard: Clipboard::Device });
} else {
responses.add(PortfolioMessage::Copy { clipboard: Clipboard::Device });
}
}
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::Layer(layer) => format!("{CLIPBOARD_PREFIX_LAYER}{layer}"),
ClipboardContent::Nodes(nodes) => format!("{CLIPBOARD_PREFIX_NODES}{nodes}"),
ClipboardContent::Vector(vector) => format!("{CLIPBOARD_PREFIX_VECTOR}{vector}"),
ClipboardContent::Text(text) => text,
};
responses.add(FrontendMessage::TriggerClipboardWrite { content: text });
}
}
}
advertise_actions!(ClipboardMessageDiscriminant;
Cut,
Copy,
Paste,
);
}
+8
View File
@@ -0,0 +1,8 @@
mod clipboard_message;
pub mod clipboard_message_handler;
pub mod utility_types;
#[doc(inline)]
pub use clipboard_message::{ClipboardMessage, ClipboardMessageDiscriminant};
#[doc(inline)]
pub use clipboard_message_handler::ClipboardMessageHandler;
@@ -0,0 +1,16 @@
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum ClipboardContentRaw {
Text(String),
Svg(String),
Image { data: Vec<u8>, width: u32, height: u32 },
}
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum ClipboardContent {
Layer(String),
Nodes(String),
Vector(String),
Text(String),
Svg(String),
Image { data: Vec<u8>, width: u32, height: u32 },
}
@@ -66,7 +66,7 @@ pub enum FrontendMessage {
shortcut: Option<ActionShortcut>,
},
// Trigger prefix: cause a browser API to do something
// Trigger prefix: cause a frontend specific API to do something
TriggerAboutGraphiteLocalizedCommitDate {
#[serde(rename = "commitDate")]
commit_date: String,
@@ -111,7 +111,6 @@ pub enum FrontendMessage {
TriggerOpenLaunchDocuments,
TriggerLoadPreferences,
TriggerOpenDocument,
TriggerPaste,
TriggerSavePreferences {
preferences: PreferencesMessageHandler,
},
@@ -120,13 +119,19 @@ pub enum FrontendMessage {
document_id: DocumentId,
},
TriggerTextCommit,
TriggerTextCopy {
#[serde(rename = "copyText")]
copy_text: String,
},
TriggerVisitLink {
url: String,
},
TriggerClipboardRead,
TriggerClipboardWrite {
content: String,
},
TriggerSelectionRead {
cut: bool,
},
TriggerSelectionWrite {
content: String,
},
// Update prefix: give the frontend a new value or state for it to use
UpdateActiveDocument {
@@ -330,12 +335,15 @@ pub enum FrontendMessage {
width: f64,
height: f64,
},
#[cfg(not(target_family = "wasm"))]
RenderOverlays {
#[serde(skip, default = "OverlayContext::default")]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
context: OverlayContext,
},
// Window prefix: cause the application window to do something
WindowClose,
WindowMinimize,
WindowMaximize,
@@ -54,6 +54,11 @@ pub fn input_mappings() -> Mapping {
// Hack to prevent Left Click + Accel + Z combo (this effectively blocks you from making a double undo with AbortTransaction)
entry!(KeyDown(KeyZ); modifiers=[Accel, MouseLeft], action_dispatch=DocumentMessage::Noop),
//
// ClipboardMessage
entry!(KeyDown(KeyX); modifiers=[Accel], action_dispatch=ClipboardMessage::Cut),
entry!(KeyDown(KeyC); modifiers=[Accel], action_dispatch=ClipboardMessage::Copy),
entry!(KeyDown(KeyV); modifiers=[Accel], action_dispatch=ClipboardMessage::Paste),
//
// NodeGraphMessage
entry!(KeyDown(MouseLeft); action_dispatch=NodeGraphMessage::PointerDown { shift_click: false, control_click: false, alt_click: false, right_click: false }),
entry!(KeyDown(MouseLeft); modifiers=[Shift], action_dispatch=NodeGraphMessage::PointerDown { shift_click: true, control_click: false, alt_click: false, right_click: false }),
@@ -433,9 +438,6 @@ pub fn input_mappings() -> Mapping {
entry!(KeyDown(KeyR); modifiers=[Alt], action_dispatch=PortfolioMessage::ToggleRulers),
entry!(KeyDown(KeyD); modifiers=[Alt], action_dispatch=PortfolioMessage::ToggleDataPanelOpen),
//
// FrontendMessage
entry!(KeyDown(KeyV); modifiers=[Accel], action_dispatch=FrontendMessage::TriggerPaste),
//
// DialogMessage
entry!(KeyDown(KeyE); modifiers=[Accel], action_dispatch=DialogMessage::RequestExportDialog),
entry!(KeyDown(KeyN); modifiers=[Accel], action_dispatch=DialogMessage::RequestNewDocumentDialog),
@@ -1,7 +1,6 @@
use crate::messages::debug::utility_types::MessageLoggingVerbosity;
use crate::messages::input_mapper::utility_types::macros::action_shortcut;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::utility_types::clipboards::Clipboard;
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GroupFolderType};
use crate::messages::prelude::*;
use graphene_std::path_bool::BooleanOperation;
@@ -196,20 +195,20 @@ impl LayoutHolder for MenuBarMessageHandler {
MenuListEntry::new("Cut")
.label("Cut")
.icon("Cut")
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::Cut))
.on_commit(|_| PortfolioMessage::Cut { clipboard: Clipboard::Device }.into())
.tooltip_shortcut(action_shortcut!(ClipboardMessageDiscriminant::Cut))
.on_commit(|_| ClipboardMessage::Cut.into())
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Copy")
.label("Copy")
.icon("Copy")
.tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::Copy))
.on_commit(|_| PortfolioMessage::Copy { clipboard: Clipboard::Device }.into())
.tooltip_shortcut(action_shortcut!(ClipboardMessageDiscriminant::Copy))
.on_commit(|_| ClipboardMessage::Copy.into())
.disabled(no_active_document || !has_selected_layers),
MenuListEntry::new("Paste")
.label("Paste")
.icon("Paste")
.tooltip_shortcut(action_shortcut!(FrontendMessageDiscriminant::TriggerPaste))
.on_commit(|_| FrontendMessage::TriggerPaste.into())
.tooltip_shortcut(action_shortcut!(ClipboardMessageDiscriminant::Paste))
.on_commit(|_| ClipboardMessage::Paste.into())
.disabled(no_active_document),
],
vec![
+2
View File
@@ -12,6 +12,8 @@ pub enum Message {
#[child]
Broadcast(BroadcastMessage),
#[child]
Clipboard(ClipboardMessage),
#[child]
Debug(DebugMessage),
#[child]
Defer(DeferMessage),
+1
View File
@@ -3,6 +3,7 @@
pub mod animation;
pub mod app_window;
pub mod broadcast;
pub mod clipboard;
pub mod debug;
pub mod defer;
pub mod dialog;
@@ -1,6 +1,7 @@
use super::utility_types::{BoxSelection, ContextMenuInformation, DragStart, FrontendNode};
use super::{document_node_definitions, node_properties};
use crate::consts::GRID_SIZE;
use crate::messages::clipboard::utility_types::ClipboardContent;
use crate::messages::input_mapper::utility_types::macros::{action_shortcut, action_shortcut_manual};
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::document_message_handler::navigation_controls;
@@ -237,11 +238,13 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
let new_ids = &all_selected_nodes.iter().enumerate().map(|(new, old)| (*old, NodeId(new as u64))).collect();
let copied_nodes = network_interface.copy_nodes(new_ids, selection_network_path).collect::<Vec<_>>();
// Prefix to show that these are nodes
let mut copy_text = String::from("graphite/nodes: ");
copy_text += &serde_json::to_string(&copied_nodes).expect("Could not serialize copy");
responses.add(FrontendMessage::TriggerTextCopy { copy_text });
let Ok(data) = serde_json::to_string(&copied_nodes) else {
log::error!("Failed to serialize nodes for clipboard");
return;
};
responses.add(ClipboardMessage::Write {
content: ClipboardContent::Nodes(data),
});
}
NodeGraphMessage::CreateNodeInLayerNoTransaction { node_type, layer } => {
let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) else {
@@ -5,10 +5,9 @@ use graph_craft::document::NodeId;
#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, PartialEq, Eq, Debug, specta::Type)]
pub enum Clipboard {
Internal,
Device,
_InternalClipboardCount, // Keep this as the last entry of **internal** clipboards since it is used for counting the number of enum variants
Device,
}
pub const INTERNAL_CLIPBOARD_COUNT: u8 = Clipboard::_InternalClipboardCount as u8;
@@ -6469,7 +6469,7 @@ mod network_interface_tests {
let serialized_nodes = frontend_messages
.into_iter()
.find_map(|msg| match msg {
FrontendMessage::TriggerTextCopy { copy_text } => Some(copy_text),
FrontendMessage::TriggerClipboardWrite { content } => Some(content),
_ => None,
})
.expect("copy message should be dispatched")
@@ -4,6 +4,7 @@ use super::utility_types::{PanelType, PersistentData};
use crate::application::generate_uuid;
use crate::consts::{DEFAULT_DOCUMENT_NAME, DEFAULT_STROKE_WIDTH, FILE_EXTENSION};
use crate::messages::animation::TimingInformation;
use crate::messages::clipboard::utility_types::ClipboardContent;
use crate::messages::dialog::simple_dialogs;
use crate::messages::frontend::utility_types::{DocumentDetails, OpenDocument};
use crate::messages::input_mapper::utility_types::input_keyboard::Key;
@@ -243,11 +244,21 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
}
}
PortfolioMessage::Copy { clipboard } => {
if context.current_tool == &ToolType::Path {
responses.add(PathToolMessage::Copy { clipboard });
return;
}
// We can't use `self.active_document()` because it counts as an immutable borrow of the entirety of `self`
let Some(active_document) = self.active_document_id.and_then(|id| self.documents.get_mut(&id)) else {
return;
};
if active_document.graph_view_overlay_open() {
responses.add(NodeGraphMessage::Copy);
return;
}
let mut copy_val = |buffer: &mut Vec<CopyBufferEntry>| {
let mut ordered_last_elements = active_document.network_interface.shallowest_unique_layers(&[]).collect::<Vec<_>>();
@@ -283,10 +294,13 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
if clipboard == Clipboard::Device {
let mut buffer = Vec::new();
copy_val(&mut buffer);
let mut copy_text = String::from("graphite/layer: ");
copy_text += &serde_json::to_string(&buffer).expect("Could not serialize paste");
responses.add(FrontendMessage::TriggerTextCopy { copy_text });
let Ok(data) = serde_json::to_string(&buffer) else {
log::error!("Failed to serialize nodes for clipboard");
return;
};
responses.add(ClipboardMessage::Write {
content: ClipboardContent::Layer(data),
});
} else {
let copy_buffer = &mut self.copy_buffer;
copy_buffer[clipboard as usize].clear();
@@ -294,6 +308,18 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
}
}
PortfolioMessage::Cut { clipboard } => {
if context.current_tool == &ToolType::Path {
responses.add(PathToolMessage::Cut { clipboard });
return;
}
if let Some(active_document) = self.active_document()
&& active_document.graph_view_overlay_open()
{
responses.add(NodeGraphMessage::Copy);
return;
}
responses.add(PortfolioMessage::Copy { clipboard });
responses.add(DocumentMessage::DeleteSelectedLayers);
}
+1
View File
@@ -7,6 +7,7 @@ pub use crate::messages::animation::{AnimationMessage, AnimationMessageDiscrimin
pub use crate::messages::app_window::{AppWindowMessage, AppWindowMessageDiscriminant, AppWindowMessageHandler};
pub use crate::messages::broadcast::event::{EventMessage, EventMessageContext, EventMessageDiscriminant, EventMessageHandler};
pub use crate::messages::broadcast::{BroadcastMessage, BroadcastMessageDiscriminant, BroadcastMessageHandler};
pub use crate::messages::clipboard::{ClipboardMessage, ClipboardMessageDiscriminant, ClipboardMessageHandler};
pub use crate::messages::debug::{DebugMessage, DebugMessageDiscriminant, DebugMessageHandler};
pub use crate::messages::defer::{DeferMessage, DeferMessageDiscriminant, DeferMessageHandler};
pub use crate::messages::dialog::export_dialog::{ExportDialogMessage, ExportDialogMessageContext, ExportDialogMessageDiscriminant, ExportDialogMessageHandler};
@@ -100,7 +100,7 @@ impl Fsm for EyedropperToolFsmState {
// Sampling -> Sampling
(EyedropperToolFsmState::SamplingPrimary | EyedropperToolFsmState::SamplingSecondary, EyedropperToolMessage::PointerMove) => {
let mouse_position = viewport.logical(input.mouse.position);
if viewport.is_in_bounds(mouse_position) {
if viewport.is_in_bounds(mouse_position + viewport.offset()) {
update_cursor_preview(responses, input, global_tool_data, None);
} else {
disable_cursor_preview(responses);
@@ -4,6 +4,7 @@ use crate::consts::{
COLOR_OVERLAY_BLUE, COLOR_OVERLAY_GRAY, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_RED, DEFAULT_STROKE_WIDTH, DOUBLE_CLICK_MILLISECONDS, DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD, DRAG_THRESHOLD,
DRILL_THROUGH_THRESHOLD, HANDLE_ROTATE_SNAP_ANGLE, SEGMENT_INSERTION_DISTANCE, SEGMENT_OVERLAY_SIZE, SELECTION_THRESHOLD, SELECTION_TOLERANCE,
};
use crate::messages::clipboard::utility_types::ClipboardContent;
use crate::messages::input_mapper::utility_types::macros::action_shortcut_manual;
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn;
use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type;
@@ -2732,10 +2733,13 @@ impl Fsm for PathToolFsmState {
}
if clipboard == Clipboard::Device {
let mut copy_text = String::from("graphite/vector: ");
copy_text += &serde_json::to_string(&buffer).expect("Could not serialize paste");
responses.add(FrontendMessage::TriggerTextCopy { copy_text });
if let Ok(data) = serde_json::to_string(&buffer) {
responses.add(ClipboardMessage::Write {
content: ClipboardContent::Vector(data),
});
} else {
log::error!("Failed to serialize nodes for clipboard");
}
}
// TODO: Add implementation for internal clipboard