diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 2c22dda15c9..bf4660e81db 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -8,13 +8,14 @@ use std::sync::mpsc::{Receiver, Sender}; use std::thread; use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; -use winit::data_transfer::{DataTransferSendBuilder, TypeHint}; +use winit::data_transfer::TypeHint; use winit::dpi::PhysicalSize; use winit::event::{ElementState, MouseButton, StartCause, WindowEvent}; use winit::event_loop::run_on_demand::EventLoopExtRunOnDemand; -use winit::event_loop::{ActiveEventLoop, AsyncRequestSerial, ControlFlow, DndAction, EventLoop}; +use winit::event_loop::{ActiveEventLoop, ControlFlow, DndAction, EventLoop}; use winit::window::WindowId; +use crate::data_transfer::{self, TransferReads}; use crate::dirs; use crate::event::{AppEvent, AppEventScheduler}; use crate::input::InputState; @@ -24,7 +25,7 @@ use crate::render::{RenderError, RenderState}; use crate::spawner::TokioSpawner; use crate::ui::{Acceleration, InputEvent, UiCommand, UiConfig, UiInstance}; use crate::window::Window; -use crate::wrapper::messages::{DesktopFrontendMessage, DesktopWrapperMessage, IngestAction}; +use crate::wrapper::messages::{DataTransfer, DesktopFrontendMessage, DesktopWrapperMessage, IngestAction}; use crate::wrapper::{DesktopWrapper, FolderStore, MmapResourceStorage, WgpuContext, WgpuExecutor, serialize_frontend_messages}; pub(crate) struct App { @@ -36,8 +37,7 @@ pub(crate) struct App { window_maximized: bool, window_fullscreen: bool, window_pending_drag: bool, - pending_dnd_fetch: Option, - pending_clipboard_fetch: Option, + transfer_reads: TransferReads, input_state: InputState, app_event_receiver: Receiver, app_event_scheduler: AppEventScheduler, @@ -133,8 +133,7 @@ impl App { window_maximized: false, window_fullscreen: false, window_pending_drag: false, - pending_dnd_fetch: None, - pending_clipboard_fetch: None, + transfer_reads: TransferReads::default(), input_state: InputState::new(), app_event_receiver, app_event_scheduler, @@ -256,16 +255,10 @@ impl App { futures::executor::block_on(dialog.pick_file()).into_iter().collect() }; - for handle in handles { - let path = handle.path().to_path_buf(); - match fs::read(&path) { - Ok(content) => { - let action = action.clone(); - let message = DesktopWrapperMessage::IngestFile { path, content, action }; - app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); - } - Err(e) => tracing::error!("Failed to read file {}: {}", path.display(), e), - } + let files = data_transfer::read_files(handles.iter().map(|handle| handle.path().to_path_buf())); + if !files.is_empty() { + let message = DesktopWrapperMessage::Ingest { content: DataTransfer(files), action }; + app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); } }); } @@ -380,8 +373,8 @@ impl App { DesktopFrontendMessage::ClipboardRead => { self.app_event_scheduler.schedule(AppEvent::ClipboardRead); } - DesktopFrontendMessage::ClipboardWrite { content, svg } => { - self.app_event_scheduler.schedule(AppEvent::ClipboardWrite { content, svg }); + DesktopFrontendMessage::ClipboardWrite(content) => { + self.app_event_scheduler.schedule(AppEvent::ClipboardWrite(content)); } DesktopFrontendMessage::PointerLock => { self.input_state.lock_pointer(); @@ -502,31 +495,15 @@ impl App { } } AppEvent::ClipboardRead => { - let result = event_loop.clipboard().and_then(|id| { - let Some(id) = id else { return Ok(None) }; - let data_transfer = event_loop.data_transfer(id)?; - if !data_transfer.has_type(&TypeHint::Plaintext) { - return Ok(None); - } - event_loop.fetch_data_transfer(id, &TypeHint::Plaintext).map(Some) - }); - match result { - Ok(Some(serial)) => self.pending_clipboard_fetch = Some(serial), - Ok(None) => self.dispatch_desktop_wrapper_message(DesktopWrapperMessage::ClipboardReadResult { content: None }), - Err(e) => { - tracing::error!("Failed to read from clipboard: {e}"); - self.dispatch_desktop_wrapper_message(DesktopWrapperMessage::ClipboardReadResult { content: None }); - } + let read = event_loop + .clipboard() + .and_then(|id| id.map_or(Ok(()), |id| self.transfer_reads.start(event_loop, id, IngestAction::Paste))); + if let Err(e) = read { + tracing::error!("Failed to read from clipboard: {e}"); } } - AppEvent::ClipboardWrite { content, svg } => { - let has_svg = svg.is_some(); - let mut builder = DataTransferSendBuilder::new((content, svg)).with_type(TypeHint::Plaintext, |(content, _), _| Some(content.clone())); - if has_svg { - builder = builder.with_type(TypeHint::Image { extension_hint: Some("svg") }, |(_, svg), _| svg.as_ref().map(|svg| svg.as_bytes().to_vec())); - } - let send_data = builder.build(); - if let Err(e) = event_loop.set_clipboard(send_data) { + AppEvent::ClipboardWrite(content) => { + if let Err(e) = event_loop.set_clipboard(data_transfer::send(content)) { tracing::error!("Failed to write to clipboard: {e}"); } } @@ -559,18 +536,16 @@ impl App { } let app_event_scheduler = self.app_event_scheduler.clone(); let _ = thread::spawn(move || { - for path in paths { + for path in &paths { tracing::info!("Opening file: {}", path.display()); - if let Ok(content) = fs::read(&path) { - let message = DesktopWrapperMessage::IngestFile { - path, - content, - action: IngestAction::Open, - }; - app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); - } else { - tracing::error!("Failed to read file: {}", path.display()); - } + } + let files = data_transfer::read_files(paths); + if !files.is_empty() { + let message = DesktopWrapperMessage::Ingest { + content: DataTransfer(files), + action: IngestAction::Open, + }; + app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); } }); } @@ -670,57 +645,25 @@ impl ApplicationHandler for App { } } WindowEvent::DragEntered { id, .. } => { - let accepts = event_loop.data_transfer(id).is_ok_and(|data_transfer| data_transfer.has_type(&TypeHint::UriList)); + let accepts = event_loop.data_transfer(id).is_ok_and(|data_transfer| { + data_transfer.has_type(&TypeHint::UriList) || data_transfer.has_type(&TypeHint::Image { extension_hint: None }) || data_transfer.has_type(&TypeHint::Plaintext) + }); let actions: &[DndAction] = if accepts { &[DndAction::Copy] } else { &[] }; if let Err(e) = event_loop.set_valid_dnd_actions(id, actions) { tracing::error!("Failed to set valid drag and drop actions: {e}"); } } - WindowEvent::DragDropped { id, .. } => match event_loop.fetch_data_transfer(id, &TypeHint::UriList) { - Ok(serial) => self.pending_dnd_fetch = Some(serial), - Err(e) => tracing::error!("Failed to fetch dropped data: {e}"), - }, - WindowEvent::DataTransferReceived { serial, ref value, .. } if self.pending_clipboard_fetch == Some(serial) => match value.try_as_string() { - Ok(content) => { - self.pending_clipboard_fetch = None; - let message = DesktopWrapperMessage::ClipboardReadResult { content: Some(content) }; - self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); + WindowEvent::DragDropped { id, .. } => { + if let Err(e) = self.transfer_reads.start(event_loop, id, IngestAction::Import) { + tracing::error!("Failed to fetch dropped data: {e}"); } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} - Err(e) => { - self.pending_clipboard_fetch = None; - tracing::error!("Failed to read from clipboard: {e}"); - let message = DesktopWrapperMessage::ClipboardReadResult { content: None }; + } + WindowEvent::DataTransferReceived { serial, ref value, .. } => { + if let Some((content, action)) = self.transfer_reads.receive(serial, value.as_ref()) { + let message = DesktopWrapperMessage::Ingest { content, action }; self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); } - }, - WindowEvent::DataTransferReceived { serial, ref value, .. } if self.pending_dnd_fetch == Some(serial) => match value.try_as_uris() { - Ok(uris) => { - self.pending_dnd_fetch = None; - let paths = uris.iter().filter_map(|uri| url::Url::parse(uri).ok()?.to_file_path().ok()); - for path in paths { - match fs::read(&path) { - Ok(content) => { - let message = DesktopWrapperMessage::IngestFile { - path, - content, - action: IngestAction::Import, - }; - self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); - } - Err(e) => { - tracing::error!("Failed to read dropped file {}: {}", path.display(), e); - return; - } - }; - } - } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} - Err(e) => { - self.pending_dnd_fetch = None; - tracing::error!("Failed to read dropped data: {e}"); - } - }, + } WindowEvent::PointerMoved { .. } | WindowEvent::PointerLeft { position: Some(_), .. } | WindowEvent::PointerEntered { .. } if !self.input_state.pointer_locked() && self.window_pending_drag => diff --git a/desktop/src/data_transfer.rs b/desktop/src/data_transfer.rs new file mode 100644 index 00000000000..4276c9e96e9 --- /dev/null +++ b/desktop/src/data_transfer.rs @@ -0,0 +1,120 @@ +use std::fs; +use std::io; +use std::path::PathBuf; +use winit::data_transfer::{DataTransferId, DataTransferSend, DataTransferSendBuilder, SendData, TypeHint, TypedData}; +use winit::error::TransferError; +use winit::event_loop::{ActiveEventLoop, AsyncRequestSerial}; + +use crate::wrapper::messages::{DataTransfer, DataTransferItem, DataType, IngestAction, RawData}; + +fn type_hint(data_type: DataType) -> Option { + match data_type { + DataType::Text => Some(TypeHint::Plaintext), + DataType::Svg | DataType::Raster(_) => Some(TypeHint::Image { + extension_hint: Some(data_type.extensions().first().copied()?), + }), + _ => None, + } +} + +fn data_type(hint: TypeHint) -> Option { + match hint { + TypeHint::Plaintext => Some(DataType::Text), + TypeHint::Image { extension_hint: Some(extension) } => match DataType::from_extension(extension) { + data_type @ (DataType::Svg | DataType::Raster(_)) => Some(data_type), + _ => None, + }, + _ => None, + } +} + +pub(crate) fn send(content: DataTransfer) -> Box { + let mut builder = DataTransferSendBuilder::new(()); + for item in content.0 { + let Some((data_type, data)) = item.into_raw() else { continue }; + let Some(hint) = type_hint(data_type) else { continue }; + let data = match (hint, data) { + (TypeHint::Plaintext, RawData::Text(text)) => SendData::String(text), + (TypeHint::Plaintext, RawData::Bytes(_)) => continue, + (_, data) => SendData::Bytes(data.into_bytes()), + }; + builder.add_type(hint, move |_, _| Some(data.clone())); + } + builder.build() +} + +pub(crate) fn read_files(paths: impl IntoIterator) -> Vec { + paths + .into_iter() + .filter_map(|path| match fs::read(&path) { + Ok(data) => Some(DataTransferItem::from_file(path, None, data)), + Err(e) => { + tracing::error!("Failed to read file {}: {e}", path.display()); + None + } + }) + .collect() +} + +#[derive(Default)] +pub(crate) struct TransferReads(Vec); + +struct TransferRead { + action: IngestAction, + fetches: Vec<(AsyncRequestSerial, TypeHint)>, + items: Vec, +} + +impl TransferReads { + pub(crate) fn start(&mut self, event_loop: &dyn ActiveEventLoop, id: DataTransferId, action: IngestAction) -> Result<(), TransferError> { + let mut hints = Vec::new(); + for hint in event_loop.data_transfer(id)?.available_types().iter().filter_map(|transfer_type| transfer_type.hint()) { + if (hint == TypeHint::UriList || data_type(hint).is_some()) && !hints.contains(&hint) { + hints.push(hint); + } + } + + let mut fetches = Vec::new(); + for hint in hints { + fetches.push((event_loop.fetch_data_transfer(id, &hint)?, hint)); + } + + if !fetches.is_empty() { + self.0.push(TransferRead { action, fetches, items: Vec::new() }); + } + Ok(()) + } + + pub(crate) fn receive(&mut self, serial: AsyncRequestSerial, value: &dyn TypedData) -> Option<(DataTransfer, IngestAction)> { + let (index, fetch) = self + .0 + .iter() + .enumerate() + .find_map(|(index, read)| Some((index, read.fetches.iter().position(|(fetch, _)| *fetch == serial)?)))?; + let read = &mut self.0[index]; + let hint = read.fetches[fetch].1; + + let received = match hint { + TypeHint::UriList => value.try_as_uris().map(|uris| { + let paths = uris.iter().filter_map(|uri| url::Url::parse(uri).ok()?.to_file_path().ok()); + read.items.extend(read_files(paths)); + }), + TypeHint::Plaintext => value.try_as_string().map(|text| read.items.extend(DataTransferItem::from_raw(DataType::Text, RawData::Text(text)))), + _ => value + .try_as_bytes() + .map(|bytes| read.items.extend(data_type(hint).and_then(|data_type| DataTransferItem::from_raw(data_type, RawData::Bytes(bytes))))), + }; + match received { + Err(e) if e.kind() == io::ErrorKind::WouldBlock => return None, + Err(e) => tracing::error!("Failed to read transferred data: {e}"), + Ok(()) => {} + } + read.fetches.remove(fetch); + if !read.fetches.is_empty() { + return None; + } + + let read = self.0.remove(index); + Some((DataTransfer(read.items), read.action)) + } +} diff --git a/desktop/src/event.rs b/desktop/src/event.rs index bee9e88d398..f58942c4ad9 100644 --- a/desktop/src/event.rs +++ b/desktop/src/event.rs @@ -1,6 +1,6 @@ use crate::ui::Cursor; use crate::wrapper::Texture; -use crate::wrapper::messages::DesktopWrapperMessage; +use crate::wrapper::messages::{DataTransfer, DesktopWrapperMessage}; pub(crate) enum AppEvent { UiUpdate(wgpu::Texture), @@ -9,10 +9,7 @@ pub(crate) enum AppEvent { DesktopWrapperMessage(DesktopWrapperMessage), ViewportTexture(Option), ClipboardRead, - ClipboardWrite { - content: String, - svg: Option, - }, + ClipboardWrite(DataTransfer), Exit, UiCrashed, OpenFiles(Vec), diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index ec405ba7972..a627eb5556b 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -14,6 +14,7 @@ pub(crate) use graphite_desktop_wrapper as wrapper; mod app; mod cli; +mod data_transfer; mod dirs; mod event; mod gpu_context; diff --git a/desktop/wrapper/src/handle_desktop_wrapper_message.rs b/desktop/wrapper/src/handle_desktop_wrapper_message.rs index 5c4bbcc2577..1e6adb83989 100644 --- a/desktop/wrapper/src/handle_desktop_wrapper_message.rs +++ b/desktop/wrapper/src/handle_desktop_wrapper_message.rs @@ -1,4 +1,3 @@ -use graphite_editor::messages::clipboard::utility_types::ClipboardContentRaw; use graphite_editor::messages::prelude::*; use super::DesktopWrapperMessageDispatcher; @@ -15,13 +14,8 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess DesktopWrapperMessage::Input(message) => { dispatcher.queue_editor_message(EditorMessage::InputPreprocessor(message)); } - DesktopWrapperMessage::IngestFile { path, content, action } => { - dispatcher.queue_editor_message(IngestMessage::Ingest { - data: content, - action, - mime_type: String::new(), - path: Some(path), - }); + DesktopWrapperMessage::Ingest { content, action } => { + dispatcher.queue_editor_message(IngestMessage::Ingest { content, action }); } DesktopWrapperMessage::SaveFileDialogResult { path, context } => match context { SaveFileDialogContext::Document { document_id, content } => { @@ -61,14 +55,6 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess } #[cfg(not(target_os = "macos"))] DesktopWrapperMessage::MenuEvent { id: _ } => {} - DesktopWrapperMessage::ClipboardReadResult { content } => { - if let Some(content) = content { - let message = ClipboardMessage::ReadClipboard { - content: ClipboardContentRaw::Text(content), - }; - dispatcher.queue_editor_message(message); - } - } DesktopWrapperMessage::PointerLockMove { x, y } => { let message = AppWindowMessage::PointerLockMove { x, y }; dispatcher.queue_editor_message(message); diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 44db5458239..13692277aca 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -97,20 +97,7 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD dispatcher.respond(DesktopFrontendMessage::ClipboardRead); } FrontendMessage::TriggerClipboardWrite { content } => { - dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { content, svg: None }); - } - FrontendMessage::TriggerClipboardSvgAndJsonWrite { svg_string, graphite_json } => { - dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { - content: graphite_json, - svg: svg_string, - }); - } - // No web origin here, so use the hosted editor - FrontendMessage::TriggerSessionLinkCopy { token } => { - dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { - content: format!("https://editor.graphite.rs/?session={token}"), - svg: None, - }); + dispatcher.respond(DesktopFrontendMessage::ClipboardWrite(content)); } FrontendMessage::WindowPointerLock => { dispatcher.respond(DesktopFrontendMessage::PointerLock); diff --git a/desktop/wrapper/src/lib.rs b/desktop/wrapper/src/lib.rs index 7d5223a10e9..7d7b2eca505 100644 --- a/desktop/wrapper/src/lib.rs +++ b/desktop/wrapper/src/lib.rs @@ -52,7 +52,12 @@ impl DesktopWrapper { let host = Host::Mac; #[cfg(target_os = "linux")] let host = Host::Linux; - let env = Environment { platform: Platform::Desktop, host }; + let url = std::env::var("GRAPHITE_URL").map_or("https://editor.graphite.art/", |url| url.leak()); + let env = Environment { + platform: Platform::Desktop, + host, + url, + }; let application_io = PlatformApplicationIo::new_with_executor(wgpu_executor); let (editor, runtime) = Builder::new() diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index 144f9766223..f5691c57a80 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -6,7 +6,7 @@ pub(crate) use graphite_editor::messages::prelude::Message as EditorMessage; pub use graphite_editor::messages::frontend::utility_types::{DocumentInfo, FileDialogOptions, FileFilter, PersistedState}; pub use graphite_editor::messages::input_mapper::utility_types::keyboard::{Key, ModifierKeys}; pub use graphite_editor::messages::input_mapper::utility_types::pointer::{EditorPointerState, MouseKeys, ScrollDelta}; -pub use graphite_editor::messages::portfolio::ingest::utility_types::IngestAction; +pub use graphite_editor::messages::portfolio::ingest::utility_types::{DataTransfer, DataTransferItem, DataType, IngestAction, RawData, TransferData}; pub use graphite_editor::messages::prelude::DocumentId; pub use graphite_editor::messages::prelude::InputPreprocessorMessage as InputMessage; pub use graphite_editor::messages::prelude::PreferencesMessageHandler as Preferences; @@ -53,10 +53,7 @@ pub enum DesktopFrontendMessage { entries: Vec, }, ClipboardRead, - ClipboardWrite { - content: String, - svg: Option, - }, + ClipboardWrite(DataTransfer), PointerLock, WindowClose, WindowMinimize, @@ -75,14 +72,13 @@ pub enum DesktopWrapperMessage { FromWeb(Box), Wake, Input(InputMessage), - IngestFile { path: PathBuf, content: Vec, action: IngestAction }, + Ingest { content: DataTransfer, action: IngestAction }, SaveFileDialogResult { path: PathBuf, context: SaveFileDialogContext }, UpdateMaximized { maximized: bool }, UpdateFullscreen { fullscreen: bool }, LoadPersistedState { state: PersistedState }, LoadPreferences { preferences: Preferences }, MenuEvent { id: String }, - ClipboardReadResult { content: Option }, PointerLockMove { x: f64, y: f64 }, LoadThirdPartyLicenses { text: String }, } diff --git a/document/peer-transport/src/token.rs b/document/peer-transport/src/token.rs index 8ef7986cf75..1a41e1ecae6 100644 --- a/document/peer-transport/src/token.rs +++ b/document/peer-transport/src/token.rs @@ -68,12 +68,12 @@ mod tests { #[test] fn parses_the_token_out_of_a_share_link() { let token = SessionToken([7; 16]); - let link = format!(" https://editor.graphite.rs/?session={token}#top "); + let link = format!(" https://editor.graphite.art/?session={token}#top "); assert_eq!(link.parse::().unwrap(), token); assert_eq!(format!("http://localhost:8080/?debug=1&session={token}").parse::().unwrap(), token); let other = SessionToken([9; 16]); - assert_eq!(format!("https://editor.graphite.rs/?next=session={other}&session={token}").parse::().unwrap(), token); - assert!(format!("https://editor.graphite.rs/?not_session={token}").parse::().is_err()); + assert_eq!(format!("https://editor.graphite.art/?next=session={other}&session={token}").parse::().unwrap(), token); + assert!(format!("https://editor.graphite.art/?not_session={token}").parse::().is_err()); } #[test] diff --git a/editor/src/application.rs b/editor/src/application.rs index 3d60214569a..08ee3b9321a 100644 --- a/editor/src/application.rs +++ b/editor/src/application.rs @@ -18,11 +18,13 @@ impl Editor { pub struct Environment { pub platform: Platform, pub host: Host, + pub url: &'static str, } #[cfg(test)] pub(crate) const DEFAULT_ENVIRONMENT: Environment = Environment { platform: Platform::Desktop, host: Host::Linux, + url: "https://editor.graphite.art/", }; #[derive(Clone, Copy, Debug)] pub enum Platform { diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index 88491444733..20aa62cca15 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -487,7 +487,7 @@ impl Dispatcher { #[cfg(test)] mod test { - use crate::messages::portfolio::ingest::utility_types::IngestAction; + use crate::messages::portfolio::ingest::utility_types::{DataTransferItem, IngestAction}; pub use crate::test_utils::test_prelude::*; #[tokio::test] @@ -566,10 +566,8 @@ mod test { ); let responses = editor.dispatch(IngestMessage::Ingest { - data, + content: DataTransferItem::from_file(file_name.into(), None, data).into(), action: IngestAction::Open, - mime_type: String::new(), - path: Some(file_name.into()), }); // Check if the graph renders diff --git a/editor/src/messages/clipboard/clipboard_message.rs b/editor/src/messages/clipboard/clipboard_message.rs index 423bd539a25..6f2d5145a47 100644 --- a/editor/src/messages/clipboard/clipboard_message.rs +++ b/editor/src/messages/clipboard/clipboard_message.rs @@ -1,4 +1,4 @@ -use crate::messages::clipboard::utility_types::{ClipboardContent, ClipboardContentRaw, ClipboardItem, ClipboardLayer, ClipboardVectorEntry}; +use crate::messages::portfolio::ingest::utility_types::{ApplicationDataItem, ApplicationDataLayer, ApplicationDataVector, DataTransfer}; use crate::messages::prelude::*; #[impl_message(Message, Clipboard)] @@ -7,14 +7,15 @@ pub enum ClipboardMessage { Cut, Copy, Paste, - ReadClipboard { content: ClipboardContentRaw }, ReadSelection { content: Option, cut: bool }, - Write { content: ClipboardContent }, + Write { content: DataTransfer }, CopyLayers, CutLayers, - WriteItems { items: Vec }, - PasteItems { data: String }, - PasteLayers { entries: Vec }, - PasteVectors { paths: Vec }, + WriteItems { items: Vec }, + Rendered { id: u64, svg: Option }, + Loaded { id: u64, content: DataTransfer }, + PasteItems { items: Vec }, + PasteLayers { entries: Vec }, + PasteVectors { paths: Vec }, } diff --git a/editor/src/messages/clipboard/clipboard_message_handler.rs b/editor/src/messages/clipboard/clipboard_message_handler.rs index 04b962472dc..7d6cd6d420c 100644 --- a/editor/src/messages/clipboard/clipboard_message_handler.rs +++ b/editor/src/messages/clipboard/clipboard_message_handler.rs @@ -1,11 +1,11 @@ use crate::consts::DEFAULT_STROKE_WIDTH; -use crate::messages::clipboard::utility_types::{ClipboardContent, ClipboardContentRaw, ClipboardItem, ClipboardLayer, ClipboardResource, ResourceData}; use crate::messages::portfolio::document::SelectionParts; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_network_node_type; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface; use crate::messages::portfolio::document::utility_types::nodes::SelectedNodes; +use crate::messages::portfolio::ingest::utility_types::{ApplicationDataItem, ApplicationDataLayer, ApplicationDataResource, DataTransferItem, ResourceData, TransferData}; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::utility_types::ToolType; @@ -19,8 +19,6 @@ use graphite_proc_macros::{ExtractField, message_handler_data}; use kurbo::ParamCurve; use std::sync::Arc; -const CLIPBOARD_PREFIX: &str = "graphite: "; - #[derive(ExtractField)] pub struct ClipboardMessageContext<'a> { pub portfolio: &'a mut PortfolioMessageHandler, @@ -29,7 +27,30 @@ pub struct ClipboardMessageContext<'a> { } #[derive(Debug, Clone, Default, ExtractField)] -pub struct ClipboardMessageHandler {} +pub struct ClipboardMessageHandler { + pending_copy: Option, + copy_id: u64, +} + +#[derive(Debug, Clone)] +enum PendingCopy { + Rendering { + id: u64, + items: Vec, + loads: Vec<(usize, ResourceHash)>, + }, + Loading { + id: u64, + }, +} + +impl PendingCopy { + fn id(&self) -> u64 { + match self { + Self::Rendering { id, .. } | Self::Loading { id } => *id, + } + } +} #[message_handler_data] impl MessageHandler> for ClipboardMessageHandler { @@ -44,19 +65,10 @@ impl MessageHandler> for Clipboard 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(graphite) = text.strip_prefix(CLIPBOARD_PREFIX) { - responses.add(ClipboardMessage::PasteItems { data: graphite.to_string() }); - } else { - responses.add(FrontendMessage::TriggerSelectionWrite { content: text }); - } - } - }, ClipboardMessage::ReadSelection { content, cut } => { if let Some(text) = content { responses.add(ClipboardMessage::Write { - content: ClipboardContent::Text(text), + content: TransferData::Text(text).into(), }); } else if cut { responses.add(ClipboardMessage::CutLayers); @@ -64,18 +76,10 @@ impl MessageHandler> for Clipboard responses.add(ClipboardMessage::CopyLayers); } } - 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(content) => { - responses.add(FrontendMessage::TriggerClipboardWrite { content }); - } - }, + ClipboardMessage::Write { content } => { + self.pending_copy = None; + responses.add(FrontendMessage::TriggerClipboardWrite { content }); + } ClipboardMessage::CopyLayers => { let Some(active_document) = portfolio.active_document() else { return }; @@ -117,7 +121,7 @@ impl MessageHandler> for Clipboard .collect::>(); tree_path.reverse(); - items.push(ClipboardItem::Layer(ClipboardLayer { + items.push(ApplicationDataItem::Layer(ApplicationDataLayer { nodes: active_document.network_interface.copy_nodes(©_ids, &[]).collect(), visible: active_document.network_interface.selected_nodes().layer_visible(layer, &active_document.network_interface), locked: active_document.network_interface.selected_nodes().layer_locked(layer, &active_document.network_interface), @@ -134,14 +138,14 @@ impl MessageHandler> for Clipboard .filter_map(|node_id| active_document.network_interface.copy_chain_node(node_id, &[])) .collect::>(); if !copies.is_empty() { - items.push(ClipboardItem::ChainNodes(copies)); + items.push(ApplicationDataItem::ChainNodes(copies)); } // Other nodes are kept under their own IDs, so a paste can tell when it's over the same nodes if !other_nodes.is_empty() { let copy_ids = active_document.with_layer_content(&other_nodes).into_iter().map(|node_id| (node_id, node_id)).collect(); let nodes = active_document.network_interface.copy_nodes(©_ids, active_document.selection_network_path()).collect(); - items.push(ClipboardItem::Nodes(nodes)); + items.push(ApplicationDataItem::Nodes(nodes)); } responses.add(ClipboardMessage::WriteItems { items }); @@ -181,12 +185,12 @@ impl MessageHandler> for Clipboard } ClipboardMessage::WriteItems { mut items } => { let has_content = items.iter().any(|item| match item { - ClipboardItem::Layer(entry) => !entry.nodes.is_empty(), - ClipboardItem::Nodes(nodes) => !nodes.is_empty(), - ClipboardItem::ChainNodes(copies) => !copies.is_empty(), - ClipboardItem::Vector(vector) => !vector.is_empty(), - ClipboardItem::Resource(_) => true, - ClipboardItem::Definition(_) => false, + ApplicationDataItem::Layer(entry) => !entry.nodes.is_empty(), + ApplicationDataItem::Nodes(nodes) => !nodes.is_empty(), + ApplicationDataItem::ChainNodes(copies) => !copies.is_empty(), + ApplicationDataItem::Vector(vector) => !vector.is_empty(), + ApplicationDataItem::Resource(_) => true, + ApplicationDataItem::Definition(_) => false, }); if !has_content { return; @@ -207,60 +211,47 @@ impl MessageHandler> for Clipboard resource_ids.insert(id); }); } - items.extend(definitions.into_iter().map(|definition| ClipboardItem::Definition(Box::new(definition)))); + items.extend(definitions.into_iter().map(|definition| ApplicationDataItem::Definition(Box::new(definition)))); // Snapshot each resource's registry entry; embedded ones also need their bytes - let mut resources = Vec::new(); - let mut bytes_to_load = Vec::new(); + let mut loads = Vec::new(); if let Some(document) = portfolio.active_document() { for id in resource_ids { let Some(info) = document.resources.registry.info(&id) else { continue }; if let Some(hash) = info.hash && info.sources.contains(&DataSource::Embedded) { - bytes_to_load.push((resources.len(), *hash)); + loads.push((items.len(), *hash)); } - resources.push(ClipboardResource { + items.push(ApplicationDataItem::Resource(ApplicationDataResource { id, hash: info.hash.copied(), sources: info.sources.to_vec(), data: None, - }); + })); } } - if bytes_to_load.is_empty() { - items.extend(resources.into_iter().map(ClipboardItem::Resource)); - if let Some(content) = serialize_clipboard(&items) { - responses.add(ClipboardMessage::Write { content }); - } + if items.iter().any(|item| matches!(item, ApplicationDataItem::Layer(_))) { + let id = self.next_copy_id(); + self.pending_copy = Some(PendingCopy::Rendering { id, items, loads }); + responses.add(PortfolioMessage::SubmitClipboardSvg { id }); } else { - // Load the embedded bytes from the resource storage, then write - let load_handle = resource_storage.resources(); - responses.add(async move { - let mut resources = resources; - for (index, hash) in bytes_to_load { - if let Some(resource) = load_handle.load(hash).await { - resources[index].data = Some(ResourceData(resource.as_ref().to_vec())); - } - } - items.extend(resources.into_iter().map(ClipboardItem::Resource)); - match serialize_clipboard(&items) { - Some(content) => ClipboardMessage::Write { content }.into(), - None => Message::NoOp, - } - }); + self.finish_copy(items, loads, None, resource_storage, responses); } } - ClipboardMessage::PasteItems { data } => { - let items = match serde_json::from_str::>(&data) { - Ok(items) => items, - Err(error) => { - log::error!("Failed to deserialize clipboard payload: {error}"); - return; - } + ClipboardMessage::Rendered { id, svg } => { + let Some(PendingCopy::Rendering { items, loads, .. }) = self.pending_copy.take_if(|pending| pending.id() == id) else { + return; }; - + self.finish_copy(items, loads, svg, resource_storage, responses); + } + ClipboardMessage::Loaded { id, content } => { + if self.pending_copy.as_ref().is_some_and(|pending| pending.id() == id) { + responses.add(ClipboardMessage::Write { content }); + } + } + ClipboardMessage::PasteItems { items } => { let mut layers = Vec::new(); let mut node_groups = Vec::new(); let mut chain_node_groups = Vec::new(); @@ -270,27 +261,27 @@ impl MessageHandler> for Clipboard for item in items { // Pasted templates skip document migration, so stored types normalize here match item { - ClipboardItem::Layer(mut entry) => { + ApplicationDataItem::Layer(mut entry) => { for (_, template) in &mut entry.nodes { template.normalize_stored_types(); } layers.push(entry); } - ClipboardItem::Nodes(mut nodes) => { + ApplicationDataItem::Nodes(mut nodes) => { for (_, template) in &mut nodes { template.normalize_stored_types(); } node_groups.push(nodes); } - ClipboardItem::ChainNodes(mut copies) => { + ApplicationDataItem::ChainNodes(mut copies) => { for copy in &mut copies { copy.template.normalize_stored_types(); } chain_node_groups.push(copies); } - ClipboardItem::Vector(vector) => vectors.push(vector), - ClipboardItem::Resource(resource) => resources.push(resource), - ClipboardItem::Definition(definition) => definitions.push(*definition), + ApplicationDataItem::Vector(vector) => vectors.push(vector), + ApplicationDataItem::Resource(resource) => resources.push(resource), + ApplicationDataItem::Definition(definition) => definitions.push(*definition), } } @@ -549,31 +540,66 @@ impl MessageHandler> for Clipboard ); } -/// Serialize the clipboard items, logging on failure. -fn serialize_clipboard(items: &[ClipboardItem]) -> Option { - match serde_json::to_string(items) { - Ok(data) => Some(ClipboardContent::Graphite(data)), - Err(error) => { - log::error!("Failed to serialize clipboard payload: {error}"); - None +impl ClipboardMessageHandler { + fn next_copy_id(&mut self) -> u64 { + self.copy_id += 1; + self.copy_id + } + + fn finish_copy( + &mut self, + mut items: Vec, + loads: Vec<(usize, ResourceHash)>, + svg: Option, + resource_storage: &ResourceStorageMessageHandler, + responses: &mut VecDeque, + ) { + if loads.is_empty() { + let content = [Some(TransferData::ApplicationData(items)), svg.map(TransferData::Svg)] + .into_iter() + .flatten() + .map(DataTransferItem::from) + .collect(); + responses.add(ClipboardMessage::Write { content }); + return; } + + let id = self.next_copy_id(); + self.pending_copy = Some(PendingCopy::Loading { id }); + + let load_handle = resource_storage.resources(); + responses.add(async move { + for (index, hash) in loads { + if let ApplicationDataItem::Resource(resource) = &mut items[index] + && let Some(data) = load_handle.load(hash).await + { + resource.data = Some(ResourceData(data.as_ref().to_vec())); + } + } + let content = [Some(TransferData::ApplicationData(items)), svg.map(TransferData::Svg)] + .into_iter() + .flatten() + .map(DataTransferItem::from) + .collect(); + Message::from(ClipboardMessage::Loaded { id, content }) + }); } } /// The node templates a clipboard item carries. -fn item_templates(item: &ClipboardItem) -> Vec<&network_interface::NodeTemplate> { +fn item_templates(item: &ApplicationDataItem) -> Vec<&network_interface::NodeTemplate> { match item { - ClipboardItem::Layer(entry) => entry.nodes.iter().map(|(_, template)| template).collect(), - ClipboardItem::Nodes(nodes) => nodes.iter().map(|(_, template)| template).collect(), - ClipboardItem::ChainNodes(copies) => copies.iter().map(|copy| ©.template).collect(), - ClipboardItem::Vector(_) | ClipboardItem::Resource(_) | ClipboardItem::Definition(_) => Vec::new(), + ApplicationDataItem::Layer(entry) => entry.nodes.iter().map(|(_, template)| template).collect(), + ApplicationDataItem::Nodes(nodes) => nodes.iter().map(|(_, template)| template).collect(), + ApplicationDataItem::ChainNodes(copies) => copies.iter().map(|copy| ©.template).collect(), + ApplicationDataItem::Vector(_) | ApplicationDataItem::Resource(_) | ApplicationDataItem::Definition(_) => Vec::new(), } } #[cfg(test)] mod test { - use crate::messages::clipboard::utility_types::{ClipboardItem, ClipboardResource, ResourceData}; use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; + use crate::messages::portfolio::ingest::utility_types::{ApplicationDataItem, ApplicationDataResource, DataType, RawData, ResourceData}; use crate::test_utils::test_prelude::*; use graph_craft::application_io::resource::{DataSource, ResourceHash, ResourceId}; use graph_craft::document::NodeInput; @@ -600,25 +626,22 @@ mod test { } /// Copies the layer selection and returns the written clipboard payload. - async fn copy_layers_to_clipboard(editor: &mut EditorTestUtils) -> String { - copy_layers_and_svg_to_clipboard(editor).await.1 - } - - /// Copies the layer selection and returns the SVG and the payload written to the clipboard. - async fn copy_layers_and_svg_to_clipboard(editor: &mut EditorTestUtils) -> (Option, String) { + async fn copy_layers_to_clipboard(editor: &mut EditorTestUtils) -> DataTransfer { editor .copy_to_clipboard(ClipboardMessage::CopyLayers) .await .expect("copying layers should write a payload to the clipboard") } - /// Pastes a clipboard string as if read from the system clipboard. - async fn paste_from_clipboard(editor: &mut EditorTestUtils, clipboard: &str) { - editor - .handle_message(ClipboardMessage::ReadClipboard { - content: ClipboardContentRaw::Text(clipboard.to_string()), - }) - .await; + fn copied_svg(clipboard: &DataTransfer) -> Option<&str> { + clipboard.0.iter().find_map(|item| match &item.data { + TransferData::Svg(svg) => Some(svg.as_str()), + _ => None, + }) + } + + fn is_svg_document(svg: &str) -> bool { + svg.starts_with("") } /// - create rect, shape and ellipse @@ -630,8 +653,8 @@ mod test { let layers = editor.active_document().metadata().all_layers().map(|layer| layer.to_node()).collect(); editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: layers }).await; - let (svg, _) = copy_layers_and_svg_to_clipboard(&mut editor).await; - let svg = svg.expect("the copied layers should render an SVG"); + let clipboard = copy_layers_to_clipboard(&mut editor).await; + let svg = copied_svg(&clipboard).expect("the copied layers should render an SVG"); // The rectangle, polygon, and ellipse are told apart by where their outlines start let position = |path_start: &str| svg.find(path_start).unwrap_or_else(|| panic!("{path_start} should be in the SVG: {svg}")); let (rectangle, polygon, ellipse) = (position(r#"d="M-100,-100 "#), position(r#"d="M-645,-400 "#), position(r#"d="M0,-400 C"#)); @@ -645,8 +668,8 @@ mod test { let mut editor = create_editor_with_three_layers().await; let layers_before_copy = editor.active_document().metadata().all_layers().collect::>(); - let (svg_string, clipboard) = copy_layers_and_svg_to_clipboard(&mut editor).await; - paste_from_clipboard(&mut editor, &clipboard).await; + let clipboard = copy_layers_to_clipboard(&mut editor).await; + editor.paste_from_clipboard(clipboard.clone()).await; let layers_after_copy = editor.active_document().metadata().all_layers().collect::>(); @@ -658,11 +681,7 @@ mod test { assert_eq!(layers_before_copy[i], layers_after_copy[i + 1]); } - assert!( - svg_string - .as_deref() - .is_some_and(|svg| svg.starts_with("")) - ); + assert!(copied_svg(&clipboard).is_some_and(is_svg_document)); } #[cfg_attr(miri, ignore)] @@ -679,8 +698,8 @@ mod test { let shape_id = editor.active_document().metadata().all_layers().nth(1).unwrap(); editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![shape_id.to_node()] }).await; - let (svg_string, clipboard) = copy_layers_and_svg_to_clipboard(&mut editor).await; - paste_from_clipboard(&mut editor, &clipboard).await; + let clipboard = copy_layers_to_clipboard(&mut editor).await; + editor.paste_from_clipboard(clipboard.clone()).await; let layers_after_copy = editor.active_document().metadata().all_layers().collect::>(); @@ -692,11 +711,7 @@ mod test { assert_eq!(layers_before_copy[i], layers_after_copy[i + 1]); } - assert!( - svg_string - .as_deref() - .is_some_and(|svg| svg.starts_with("")) - ); + assert!(copied_svg(&clipboard).is_some_and(is_svg_document)); } #[cfg_attr(miri, ignore)] @@ -722,11 +737,11 @@ mod test { nodes: vec![rect_id.to_node(), ellipse_id.to_node()], }) .await; - let (svg_string, clipboard) = copy_layers_and_svg_to_clipboard(&mut editor).await; + let clipboard = copy_layers_to_clipboard(&mut editor).await; editor.handle_message(NodeGraphMessage::DeleteSelectedNodes { delete_children: true }).await; editor.draw_rect(0., 800., 12., 200.).await; - paste_from_clipboard(&mut editor, &clipboard).await; - paste_from_clipboard(&mut editor, &clipboard).await; + editor.paste_from_clipboard(clipboard.clone()).await; + editor.paste_from_clipboard(clipboard.clone()).await; let layers_after_copy = editor.active_document().metadata().all_layers().collect::>(); @@ -734,11 +749,128 @@ mod test { assert_eq!(layers_after_copy.len(), 6); assert_eq!(layers_after_copy[5], shape_id); - assert!( - svg_string - .as_deref() - .is_some_and(|svg| svg.starts_with("")) - ); + assert!(copied_svg(&clipboard).is_some_and(is_svg_document)); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn cutting_an_embedded_image_carries_its_svg_and_bytes() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.create_raster_image(Image::new(2, 2, Color::RED), None).await; + let layers = editor.active_document().metadata().all_layers().map(|layer| layer.to_node()).collect(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: layers }).await; + + let clipboard = editor.copy_to_clipboard(ClipboardMessage::CutLayers).await.expect("cutting the image should write it to the clipboard"); + assert_eq!(editor.active_document().metadata().all_layers().count(), 0); + assert!(copied_svg(&clipboard).is_some_and(is_svg_document), "the cut image should render an SVG: {clipboard:?}"); + + let items = clipboard + .0 + .iter() + .find_map(|item| match &item.data { + TransferData::ApplicationData(items) => Some(items), + _ => None, + }) + .expect("the cut image should be written as application data"); + assert!(items.iter().any(|item| matches!(item, ApplicationDataItem::Resource(ApplicationDataResource { data: Some(_), .. })))); + } + + #[tokio::test] + async fn copying_nodes_writes_only_graphite_data() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + let rectangle = editor + .create_node_by_name(DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::rectangle::IDENTIFIER)) + .await; + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![rectangle] }).await; + + let clipboard = editor.copy_to_clipboard(NodeGraphMessage::Copy).await.expect("copying the node should write it to the clipboard"); + assert!(clipboard.0.iter().all(|item| matches!(item.data, TransferData::ApplicationData(_))), "{clipboard:?}"); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn copying_text_while_layers_render_keeps_the_text() { + let mut editor = create_editor_with_three_layers().await; + let layers = editor.active_document().metadata().all_layers().map(|layer| layer.to_node()).collect(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: layers }).await; + + let mut written = editor.dispatch(ClipboardMessage::CopyLayers); + written.extend(editor.dispatch(ClipboardMessage::ReadSelection { + content: Some("text".to_string()), + cut: false, + })); + editor.runtime.update(); + editor.runtime.run().await; + written.extend(editor.dispatch(Message::Wake)); + + let last = written + .into_iter() + .filter_map(|message| match message { + FrontendMessage::TriggerClipboardWrite { content } => Some(content), + _ => None, + }) + .last(); + assert_eq!(last, Some(TransferData::Text("text".to_string()).into())); + } + + const PASTED_SVG: &str = r#""#; + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn pasting_images_ingests_them() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + let layer_count = |editor: &EditorTestUtils| editor.active_document().metadata().all_layers().count(); + + editor.paste_from_clipboard(TransferData::Svg(PASTED_SVG.to_string()).into()).await; + let after_svg = layer_count(&editor); + assert!(after_svg > 0, "pasting an SVG should add it as a layer"); + + let data = Image::new(2, 2, Color::RED).to_png(); + editor + .paste_from_clipboard( + TransferData::Raster { + format: image::ImageFormat::Png, + data, + } + .into(), + ) + .await; + assert!(layer_count(&editor) > after_svg, "pasting a PNG should add it as a layer"); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn pasting_unreadable_graphite_data_falls_back_to_its_svg() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let unreadable = DataTransferItem::from_raw(DataType::Text, RawData::Text("graphite: unreadable".to_string())).unwrap(); + let content = [unreadable, TransferData::Svg(PASTED_SVG.to_string()).into()].into_iter().collect(); + editor.paste_from_clipboard(content).await; + assert!(editor.active_document().metadata().all_layers().count() > 0); + } + + #[tokio::test] + async fn pasting_text_inserts_it_at_the_caret() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let responses = editor.paste_from_clipboard(TransferData::Text("text".to_string()).into()).await; + assert!(responses.contains(&FrontendMessage::TriggerSelectionWrite { content: "text".to_string() })); + } + + #[tokio::test] + async fn pasting_text_that_only_looks_like_application_data_inserts_it_at_the_caret() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let text = "graphite: not a payload".to_string(); + let content = DataTransferItem::from_raw(DataType::Text, RawData::Text(text.clone())).unwrap().into(); + let responses = editor.paste_from_clipboard(content).await; + assert!(responses.contains(&FrontendMessage::TriggerSelectionWrite { content: text })); } /// A pasted `graphite:` payload re-registers the resources it carries into the active document. @@ -753,19 +885,15 @@ mod test { // Carry the resource and its bytes through the clipboard let bytes = b"a pretend png".to_vec(); let hash = ResourceHash::from(bytes.as_slice()); - let items = vec![ClipboardItem::Resource(ClipboardResource { + let items = vec![ApplicationDataItem::Resource(ApplicationDataResource { id, hash: Some(hash), sources: vec![DataSource::Embedded], data: Some(ResourceData(bytes)), })]; - let payload = format!("graphite: {}", serde_json::to_string(&items).unwrap()); + let payload = TransferData::ApplicationData(items); - editor - .handle_message(ClipboardMessage::ReadClipboard { - content: ClipboardContentRaw::Text(payload), - }) - .await; + editor.paste_from_clipboard(payload.into()).await; let registry = &editor.active_document().resources.registry; assert!(registry.contains(&id), "the carried resource should be registered after paste"); @@ -802,7 +930,7 @@ mod test { editor.draw_rect(0., 0., 100., 100.).await; let clipboard = copy_layers_to_clipboard(&mut editor).await; - paste_from_clipboard(&mut editor, &clipboard).await; + editor.paste_from_clipboard(clipboard.clone()).await; let entries = layer_entries(&editor); assert_eq!(entries.len(), 2); @@ -821,11 +949,11 @@ mod test { let clipboard = copy_layers_to_clipboard(&mut editor).await; editor.new_document().await; - paste_from_clipboard(&mut editor, &clipboard).await; + editor.paste_from_clipboard(clipboard.clone()).await; assert_eq!(layer_entries(&editor), copied); assert_consistent(&editor); - paste_from_clipboard(&mut editor, &clipboard).await; + editor.paste_from_clipboard(clipboard.clone()).await; assert_eq!(layer_entries(&editor), [copied.clone(), copied].concat(), "identical content should be reused"); assert_eq!(editor.active_document().network_interface.document_network().definitions.len(), 1); assert_consistent(&editor); @@ -848,7 +976,7 @@ mod test { let clipboard = copy_layers_to_clipboard(&mut editor).await; editor.new_document().await; - paste_from_clipboard(&mut editor, &clipboard).await; + editor.paste_from_clipboard(clipboard.clone()).await; let [entry] = layer_entries(&editor)[..] else { panic!("the paste should add one layer") }; // Diverge the pasted entry from the copied one @@ -872,7 +1000,7 @@ mod test { interface.set_input(&InputConnector::node_at_index(inner, input_index), flipped, &[layer]); assert_ne!(interface.document_network().definitions[&entry], before, "the edit should change the pasted entry"); - paste_from_clipboard(&mut editor, &clipboard).await; + editor.paste_from_clipboard(clipboard.clone()).await; let entries = layer_entries(&editor); assert_eq!(entries.len(), 2); assert!(entries.contains(&entry)); diff --git a/editor/src/messages/clipboard/mod.rs b/editor/src/messages/clipboard/mod.rs index 5d48836c077..61ddba57690 100644 --- a/editor/src/messages/clipboard/mod.rs +++ b/editor/src/messages/clipboard/mod.rs @@ -1,6 +1,5 @@ mod clipboard_message; pub mod clipboard_message_handler; -pub mod utility_types; #[doc(inline)] pub use clipboard_message::{ClipboardMessage, ClipboardMessageDiscriminant}; diff --git a/editor/src/messages/clipboard/utility_types.rs b/editor/src/messages/clipboard/utility_types.rs deleted file mode 100644 index 95ebd7a1103..00000000000 --- a/editor/src/messages/clipboard/utility_types.rs +++ /dev/null @@ -1,71 +0,0 @@ -use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; -use crate::messages::portfolio::document::utility_types::network_interface::{ChainNodeCopy, CopiedDefinition, NodeTemplate}; -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64; -use glam::DAffine2; -use graph_craft::application_io::resource::{DataSource, ResourceHash, ResourceId}; -use graph_craft::document::NodeId; -use graphene_std::vector::VectorModification; - -#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] -pub enum ClipboardContentRaw { - Text(String), -} - -#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] -pub enum ClipboardContent { - Graphite(String), - Text(String), - Image { data: Vec, width: u32, height: u32 }, -} - -pub type ClipboardVectorEntry = (LayerNodeIdentifier, VectorModification, DAffine2); - -/// An entry in the `graphite:` clipboard payload. -#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] -pub enum ClipboardItem { - Layer(ClipboardLayer), - Nodes(Vec<(NodeId, NodeTemplate)>), - /// Nodes copied from layers' chains, ordered by their layer's place in the Layers panel, then from the layer outward. - ChainNodes(Vec), - Vector(Vec), - Resource(ClipboardResource), - /// A definitions table entry the copied nodes name, listed after the entries it names. - Definition(Box), -} - -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct ClipboardLayer { - pub nodes: Vec<(NodeId, NodeTemplate)>, - pub visible: bool, - pub locked: bool, - pub collapsed: bool, -} - -#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct ClipboardResource { - pub id: ResourceId, - pub hash: Option, - pub sources: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub data: Option, -} - -#[derive(PartialEq, Clone)] -pub struct ResourceData(pub Vec); -impl std::fmt::Debug for ResourceData { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ResourceData").field("len", &self.0.len()).finish() - } -} -impl serde::Serialize for ResourceData { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&BASE64.encode(&self.0)) - } -} -impl<'de> serde::Deserialize<'de> for ResourceData { - fn deserialize>(deserializer: D) -> Result { - let encoded = String::deserialize(deserializer)?; - Ok(Self(BASE64.decode(&encoded).map_err(serde::de::Error::custom)?)) - } -} diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 106ddc97674..264ce5e693b 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -10,7 +10,7 @@ use crate::messages::portfolio::document::node_graph::utility_types::{ }; use crate::messages::portfolio::document::utility_types::nodes::{LayerPanelEntry, LayerStructureEntry}; use crate::messages::portfolio::document::utility_types::wires::{WirePath, WirePathUpdate}; -use crate::messages::portfolio::ingest::utility_types::IngestAction; +use crate::messages::portfolio::ingest::utility_types::{DataTransfer, IngestAction}; use crate::messages::portfolio::utility_types::WorkspacePanelLayout; use crate::messages::prelude::*; use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary; @@ -140,17 +140,9 @@ pub enum FrontendMessage { url: String, }, TriggerClipboardRead, - TriggerSessionLinkCopy { - token: String, - }, TriggerClipboardWrite { - content: String, - }, - TriggerClipboardSvgAndJsonWrite { - #[serde(rename = "svgString")] - svg_string: Option, - #[serde(rename = "graphiteJson")] - graphite_json: String, + #[cfg_attr(feature = "wasm", tsify(type = "unknown"))] + content: DataTransfer, }, TriggerSelectionRead { cut: bool, diff --git a/editor/src/messages/portfolio/document/node_graph/node_inspector.rs b/editor/src/messages/portfolio/document/node_graph/node_inspector.rs index da14320a022..8540d739156 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_inspector.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_inspector.rs @@ -10,7 +10,7 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions: use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, LAYER_SECONDARY_INPUT_INDEX, NodeNetworkInterface, OutputConnector}; use crate::messages::portfolio::fonts::utility_types::FontCatalogStyle; -use crate::messages::portfolio::ingest::utility_types::{IngestAction, TypeFilter}; +use crate::messages::portfolio::ingest::utility_types::{DataTransferItem, IngestAction, TypeFilter}; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils; use choice::enum_choice; @@ -1578,10 +1578,8 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, filters: Ve .selected_index(selected_index) .on_file_drop(move |file| { IngestMessage::Ingest { - data: file.data.clone(), + content: DataTransferItem::from_file(PathBuf::from(&file.name), Some(file.mime_type.as_str()).filter(|mime| !mime.is_empty()), file.data.clone()).into(), action: file_drop_action.clone(), - mime_type: file.mime_type.clone(), - path: Some(PathBuf::from(&file.name)), } .into() }) diff --git a/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs b/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs index abcb0b6cf2a..79a9a6f5847 100644 --- a/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs +++ b/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs @@ -16,7 +16,7 @@ use crate::messages::portfolio::document::document_message_handler::DocumentMess use crate::messages::portfolio::document::utility_types::misc::GroupFolderType; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{StorageMetadataView, build_interface_from_storage}; -use crate::messages::portfolio::ingest::utility_types::IngestAction; +use crate::messages::portfolio::ingest::utility_types::{DataTransferItem, IngestAction}; use crate::test_utils::test_prelude::*; use graphene_std::NodeParameter; use graphene_std::vector::style::RenderMode; @@ -486,10 +486,8 @@ async fn live_undo_new_document_draw_rect() { fn paste_named_image() -> IngestMessage { IngestMessage::Ingest { - data: Image::new(2, 2, Color::WHITE).to_png(), + content: DataTransferItem::from_file("pasted.png".into(), None, Image::new(2, 2, Color::WHITE).to_png()).into(), action: IngestAction::Paste, - mime_type: String::new(), - path: Some("pasted.png".into()), } } @@ -820,10 +818,8 @@ async fn demo_artwork_edit_autosaves_and_round_trips() { let data = std::fs::read("../demo-artwork/changing-seasons.graphite").expect("read demo artwork"); editor .handle_message(IngestMessage::Ingest { - data, + content: DataTransferItem::from_file("changing-seasons.graphite".into(), None, data).into(), action: IngestAction::Open, - mime_type: String::new(), - path: Some("changing-seasons.graphite".into()), }) .await; @@ -869,10 +865,8 @@ async fn demo_artwork_vector_modifications_still_apply() { let data = std::fs::read(format!("../demo-artwork/{name}.graphite")).expect("read demo artwork"); editor .handle_message(IngestMessage::Ingest { - data, + content: DataTransferItem::from_file(format!("{name}.graphite").into(), None, data).into(), action: IngestAction::Open, - mime_type: String::new(), - path: Some(format!("{name}.graphite").into()), }) .await; 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 4f47ffa6f4b..0c6f71ae76b 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -236,13 +236,9 @@ mod network_interface_tests { .create_node_by_name(DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::rectangle::IDENTIFIER)) .await; editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![rectangle] }).await; - let (_, clipboard) = editor.copy_to_clipboard(NodeGraphMessage::Copy).await.expect("copy message should be dispatched"); - println!("Clipboard: {clipboard}"); - editor - .handle_message(ClipboardMessage::ReadClipboard { - content: ClipboardContentRaw::Text(clipboard), - }) - .await; + let clipboard = editor.copy_to_clipboard(NodeGraphMessage::Copy).await.expect("copy message should be dispatched"); + println!("Clipboard: {clipboard:?}"); + editor.paste_from_clipboard(clipboard).await; let nodes = &editor.active_document().network_interface.document_network().nodes; let original = nodes.get(&rectangle).expect("original node should exist"); assert!( diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/characterization_tests.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/characterization_tests.rs index e927ae976d0..0378ee63bfe 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/characterization_tests.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/characterization_tests.rs @@ -455,7 +455,7 @@ async fn cutting_a_chain_node_and_pasting_it_back_keeps_its_wires() { // Cutting removes just the node, keeping the node that fed its second input editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![near] }).await; - let (_, clipboard) = editor + let clipboard = editor .copy_to_clipboard(ClipboardMessage::CutLayers) .await .expect("Cutting a chain node should write it to the clipboard"); @@ -465,11 +465,7 @@ async fn cutting_a_chain_node_and_pasting_it_back_keeps_its_wires() { // Pasting with the layer selected puts the node back beside the layer, wired as before editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer] }).await; - editor - .handle_message(ClipboardMessage::ReadClipboard { - content: ClipboardContentRaw::Text(clipboard), - }) - .await; + editor.paste_from_clipboard(clipboard).await; let network_interface = &editor.active_document().network_interface; let chain = chain_of(network_interface, layer); assert_eq!(chain.len(), 3, "The pasted node should join the chain"); @@ -751,18 +747,13 @@ async fn duplicating_a_solo_floating_layer_makes_an_offset_copy_with_its_content assert_invariants(&editor, "after duplicating a solo floating layer"); } -async fn copy_in_graph(editor: &mut EditorTestUtils, nodes: Vec) -> String { +async fn copy_in_graph(editor: &mut EditorTestUtils, nodes: Vec) -> DataTransfer { editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes }).await; - let (_, clipboard) = editor.copy_to_clipboard(NodeGraphMessage::Copy).await.expect("Copying nodes should write them to the clipboard"); - clipboard + editor.copy_to_clipboard(NodeGraphMessage::Copy).await.expect("Copying nodes should write them to the clipboard") } -async fn paste(editor: &mut EditorTestUtils, clipboard: String) -> Vec { - editor - .handle_message(ClipboardMessage::ReadClipboard { - content: ClipboardContentRaw::Text(clipboard), - }) - .await; +async fn paste(editor: &mut EditorTestUtils, clipboard: DataTransfer) -> Vec { + editor.paste_from_clipboard(clipboard).await; editor.active_document().network_interface.selected_nodes().selected_nodes().copied().collect() } diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 97378da8cfb..36e4d9a3073 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -5586,7 +5586,7 @@ mod tests { #[tokio::test] async fn copy_to_points_opens_as_a_chain_that_compiles() { - use crate::messages::portfolio::ingest::utility_types::IngestAction; + use crate::messages::portfolio::ingest::utility_types::{DataTransferItem, IngestAction}; use crate::test_utils::test_prelude::*; use graphene_std::NodeParameter; use graphene_std::graphic::{flatten_graphic, flatten_vector, into_group}; @@ -5658,10 +5658,8 @@ mod tests { // Opening migrates the document, then evaluates its graph and checks the interface, failing on any error editor .handle_message(IngestMessage::Ingest { - data: serialized.into_bytes(), + content: DataTransferItem::from_file("copy-to-points.graphite".into(), None, serialized.into_bytes()).into(), action: IngestAction::Open, - mime_type: String::new(), - path: Some("copy-to-points.graphite".into()), }) .await; @@ -5867,7 +5865,7 @@ mod tests { #[tokio::test] async fn assign_colors_splits_by_its_fill_and_stroke_toggles() { - use crate::messages::portfolio::ingest::utility_types::IngestAction; + use crate::messages::portfolio::ingest::utility_types::{DataTransferItem, IngestAction}; use crate::test_utils::test_prelude::*; use graphene_std::NodeParameter; use graphene_std::vector::generator_nodes::rectangle; @@ -5919,10 +5917,8 @@ mod tests { // Opening migrates the document, then evaluates its graph and checks the interface, failing on any error editor .handle_message(IngestMessage::Ingest { - data: serialized.into_bytes(), + content: DataTransferItem::from_file("assign-colors.graphite".into(), None, serialized.into_bytes()).into(), action: IngestAction::Open, - mime_type: String::new(), - path: Some("assign-colors.graphite".into()), }) .await; diff --git a/editor/src/messages/portfolio/ingest/ingest_message.rs b/editor/src/messages/portfolio/ingest/ingest_message.rs index cb8bdaf6eab..68f4e60bd16 100644 --- a/editor/src/messages/portfolio/ingest/ingest_message.rs +++ b/editor/src/messages/portfolio/ingest/ingest_message.rs @@ -1,16 +1,17 @@ -use super::utility_types::{IngestAction, TypeFilter}; +use super::utility_types::{DataTransfer, DataTransferItem, IngestAction, TypeFilter}; use crate::messages::prelude::*; use graph_craft::document::NodeId; -use std::path::PathBuf; #[impl_message(Message, PortfolioMessage, Ingest)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum IngestMessage { Ingest { - data: Vec, + content: DataTransfer, + action: IngestAction, + }, + Item { + item: DataTransferItem, action: IngestAction, - mime_type: String, - path: Option, }, Browse { filters: Vec, diff --git a/editor/src/messages/portfolio/ingest/ingest_message_handler.rs b/editor/src/messages/portfolio/ingest/ingest_message_handler.rs index 31d66bfa65d..ce29d4d6b0e 100644 --- a/editor/src/messages/portfolio/ingest/ingest_message_handler.rs +++ b/editor/src/messages/portfolio/ingest/ingest_message_handler.rs @@ -1,4 +1,4 @@ -use super::utility_types::{DataType, IngestAction, TypeFilter}; +use super::utility_types::{DataTransferItem, DataType, IngestAction, TransferData, TypeFilter}; use crate::messages::frontend::utility_types::{FileDialogOptions, FileFilter}; use crate::messages::prelude::*; use glam::IVec2; @@ -46,10 +46,46 @@ impl MessageHandler for IngestMessageHandle }, action, }), - IngestMessage::Ingest { data, action, mime_type, path } => { - let data_type = DataType::detect(&data, &mime_type, path.as_deref()); + IngestMessage::Ingest { content, action } => { + let (files, mut items): (Vec<_>, Vec<_>) = content.0.into_iter().partition(|item| item.path.is_some()); - let fresh_identity = matches!(action, IngestAction::OpenDemo); + if let Some(index) = items.iter().position(|item| matches!(item.data, TransferData::ApplicationData(_))) + && let TransferData::ApplicationData(pasted) = items.swap_remove(index).data + { + responses.add(ClipboardMessage::PasteItems { items: pasted }); + return; + } + + if !files.is_empty() { + for item in files { + responses.add(IngestMessage::Item { item, action: action.clone() }); + } + return; + } + + let preferred = items.into_iter().min_by_key(|item| match item.data { + TransferData::Svg(_) => 0, + TransferData::Raster { .. } => 1, + TransferData::Document(_) | TransferData::LegacyDocument(_) | TransferData::Lut(_) | TransferData::Other(_) => 2, + TransferData::Text(_) => 3, + TransferData::ApplicationData(_) => 4, + }); + match preferred { + Some(DataTransferItem { + data: TransferData::Text(content), .. + }) if action == IngestAction::Paste => responses.add(FrontendMessage::TriggerSelectionWrite { content }), + Some(DataTransferItem { + data: TransferData::ApplicationData(_) | TransferData::Text(_), + .. + }) + | None => {} + Some(item) => responses.add(IngestMessage::Item { item, action }), + } + } + IngestMessage::Item { item, action } => { + let DataTransferItem { data, path } = item; + + let fresh_identity = matches!(action, IngestAction::OpenUnique); let placement = match action { IngestAction::ResourceInput { document_id, @@ -57,6 +93,8 @@ impl MessageHandler for IngestMessageHandle input_index, accepted_types, } => { + let data_type = data.data_type(); + let data = data.into_bytes(); if let Some(description) = rejection(&data, data_type, &accepted_types) { responses.add(DialogMessage::DisplayDialogError { title: "Unsupported file".into(), @@ -81,7 +119,7 @@ impl MessageHandler for IngestMessageHandle } return; } - IngestAction::Open | IngestAction::OpenDemo => None, + IngestAction::Open | IngestAction::OpenUnique => None, IngestAction::Import | IngestAction::Paste => Some((None, None)), IngestAction::DropOnCanvas { mouse } => Some((Some(mouse), None)), IngestAction::DropOnLayers { parent, insert_index } => Some((None, Some((parent, insert_index as usize)))), @@ -93,11 +131,8 @@ impl MessageHandler for IngestMessageHandle let (mouse, parent_and_insert_index) = placement.unwrap_or_default(); let place_at_origin = placement.is_none(); - let (insert, artboard_canvas) = match data_type { - DataType::GraphiteLegacy => { - let Ok(document_serialized_content) = String::from_utf8(data) else { - return unsupported(responses); - }; + let (insert, artboard_canvas) = match data { + TransferData::LegacyDocument(document_serialized_content) => { responses.add(PortfolioMessage::OpenLegacyDocumentFile { document_name: name, document_path, @@ -105,17 +140,16 @@ impl MessageHandler for IngestMessageHandle }); return; } - DataType::Gdd => { + TransferData::Document(content) => { responses.add(PortfolioMessage::OpenDocumentFile { document_name: name, document_path, - content: data, + content, fresh_identity, }); return; } - DataType::Svg => { - let Ok(svg) = String::from_utf8(data) else { return unsupported(responses) }; + TransferData::Svg(svg) => { let artboard_canvas = place_at_origin.then(|| svg_canvas(&svg)).flatten(); let insert = DocumentMessage::InsertSvg { name: name.clone(), @@ -126,7 +160,7 @@ impl MessageHandler for IngestMessageHandle }; (insert, artboard_canvas) } - DataType::Raster(_) => { + TransferData::Raster { data, .. } => { let Some(size) = Image::encoded_size(&data) else { return unsupported(responses) }; let insert = DocumentMessage::InsertImage { name: name.clone(), @@ -138,8 +172,7 @@ impl MessageHandler for IngestMessageHandle }; (insert, None) } - // A LUT is only ever the file of a node input - DataType::Lut | DataType::Unknown => return unsupported(responses), + TransferData::Lut(_) | TransferData::Other(_) | TransferData::Text(_) | TransferData::ApplicationData(_) => return unsupported(responses), }; if !place_at_origin { @@ -215,8 +248,10 @@ fn svg_canvas(svg: &str) -> Option<(IVec2, IVec2)> { #[cfg(test)] mod tests { use super::*; + use crate::messages::portfolio::ingest::utility_types::DataTransfer; use graph_craft::document::NodeId; use graphene_std::Color; + use std::path::PathBuf; const REQUESTING_DOCUMENT: DocumentId = DocumentId(3); const IDENTITY_CUBE: &[u8] = b"LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; @@ -227,11 +262,11 @@ mod tests { fn ingest_named(data: &[u8], file_name: Option<&str>, action: IngestAction, document_open: bool) -> VecDeque { let mut responses = VecDeque::new(); - let message = IngestMessage::Ingest { - data: data.into(), + let path = file_name.map(PathBuf::from); + let data = DataTransferItem::from_file(path.clone().unwrap_or_default(), None, data.to_vec()).data; + let message = IngestMessage::Item { + item: DataTransferItem { data, path }, action, - mime_type: String::new(), - path: file_name.map(Into::into), }; IngestMessageHandler::default().process_message(message, &mut responses, IngestMessageContext { document_open }); responses @@ -256,6 +291,41 @@ mod tests { } } + fn ingest_content(content: DataTransfer) -> Vec { + let mut responses = VecDeque::new(); + let message = IngestMessage::Ingest { + content, + action: IngestAction::Import, + }; + IngestMessageHandler::default().process_message(message, &mut responses, IngestMessageContext { document_open: true }); + responses + .into_iter() + .filter_map(|message| match message { + Message::Portfolio(PortfolioMessage::Ingest(IngestMessage::Item { item, .. })) => Some(item), + _ => None, + }) + .collect() + } + + #[test] + fn ingesting_files_ingests_each_one_in_place_of_other_representations() { + let file = |name: &str| DataTransferItem::from_file(name.into(), None, Vec::new()); + let content = [DataTransferItem::from(TransferData::Svg("".to_string())), file("a.png"), file("b.svg")].into_iter().collect(); + let paths = ingest_content(content).into_iter().map(|item| item.path).collect::>(); + assert_eq!(paths, [Some("a.png".into()), Some("b.svg".into())]); + } + + #[test] + fn ingesting_images_prefers_svg_over_raster() { + let raster = TransferData::Raster { + format: image::ImageFormat::Png, + data: Vec::new(), + }; + let svg = TransferData::Svg("".to_string()); + let content = [raster, svg.clone()].into_iter().map(DataTransferItem::from).collect(); + assert_eq!(ingest_content(content), [DataTransferItem::from(svg)]); + } + #[test] fn resource_input_stores_the_file_and_assigns_it_in_the_requesting_document() { let responses = ingest(b"any bytes", resource_input(Vec::new()), true); diff --git a/editor/src/messages/portfolio/ingest/utility_types.rs b/editor/src/messages/portfolio/ingest/utility_types.rs index bcf6e9c343e..9631e6acb60 100644 --- a/editor/src/messages/portfolio/ingest/utility_types.rs +++ b/editor/src/messages/portfolio/ingest/utility_types.rs @@ -1,23 +1,33 @@ use crate::consts::{FILE_EXTENSION, GDD_FILE_EXTENSION}; use crate::messages::frontend::utility_types::FileFilter; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::portfolio::document::utility_types::network_interface::{ChainNodeCopy, CopiedDefinition, NodeTemplate}; use crate::messages::prelude::DocumentId; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; use document_container::archive::ArchiveFormat; +use glam::DAffine2; +use graph_craft::application_io::resource::{DataSource, ResourceHash, ResourceId}; use graph_craft::document::NodeId; use graphene_std::raster_nodes::color_lookup_table::LUT_FILE_EXTENSIONS; +use graphene_std::vector::VectorModification; use image::ImageFormat; use std::ffi::OsStr; -use std::path::Path; +use std::path::{Path, PathBuf}; -/// How many leading bytes are inspected to recognize a text format. +const GDD_MIME: &str = "application/vnd.graphite.document"; +const GRAPHITE_LEGACY_MIME: &str = "application/graphite+json"; +const SVG_MIME: &str = "image/svg+xml"; +const TEXT_MIME: &str = "text/plain"; + +const GRAPHITE_PREFIX: &str = "graphite: "; const SNIFFED_TEXT_LENGTH: usize = 4096; #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] pub enum IngestAction { Open, - /// Open a distributed file such as a demo artwork as a copy with its own session room. - OpenDemo, + OpenUnique, Import, Paste, DropOnCanvas { @@ -56,15 +66,15 @@ pub enum DataType { Svg, Raster(ImageFormat), Lut, + Text, #[default] Unknown, } impl DataType { - /// The content decides, and the MIME type then the file name only settle what it leaves unknown. - pub fn detect(data: &[u8], mime_type: &str, path: Option<&Path>) -> Self { + pub fn detect(data: &[u8], mime: Option<&str>, path: Option<&Path>) -> Self { match Self::from_content(data) { - Self::Unknown => match Self::from_mime(mime_type) { + Self::Unknown => match mime.map_or(Self::Unknown, Self::from_mime) { Self::Unknown => path.map_or(Self::Unknown, Self::from_path), data_type => data_type, }, @@ -74,9 +84,10 @@ impl DataType { pub fn from_mime(mime: &str) -> Self { match mime.to_ascii_lowercase().as_str() { - "application/graphite+json" => Self::GraphiteLegacy, - "application/vnd.graphite.document" => Self::Gdd, - "image/svg+xml" => Self::Svg, + GRAPHITE_LEGACY_MIME => Self::GraphiteLegacy, + GDD_MIME => Self::Gdd, + SVG_MIME => Self::Svg, + TEXT_MIME => Self::Text, mime => ImageFormat::from_mime_type(mime).map_or(Self::Unknown, Self::Raster), } } @@ -123,10 +134,11 @@ impl DataType { pub fn mime(self) -> Option<&'static str> { Some(match self { - Self::GraphiteLegacy => "application/graphite+json", - Self::Gdd => "application/vnd.graphite.document", - Self::Svg => "image/svg+xml", + Self::GraphiteLegacy => GRAPHITE_LEGACY_MIME, + Self::Gdd => GDD_MIME, + Self::Svg => SVG_MIME, Self::Raster(format) => format.to_mime_type(), + Self::Text => TEXT_MIME, // The LUT formats share no MIME type Self::Lut | Self::Unknown => return None, }) @@ -139,7 +151,7 @@ impl DataType { Self::Svg => &["svg"], Self::Raster(format) => format.extensions_str(), Self::Lut => LUT_FILE_EXTENSIONS, - Self::Unknown => &[], + Self::Text | Self::Unknown => &[], } } } @@ -189,6 +201,194 @@ impl From for FileFilter { } } +#[derive(PartialEq, Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub struct DataTransfer(pub Vec); + +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct DataTransferItem { + pub data: TransferData, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum TransferData { + Text(String), + ApplicationData(Vec), + Svg(String), + Raster { format: ImageFormat, data: Vec }, + Document(Vec), + LegacyDocument(String), + Lut(Vec), + Other(Vec), +} + +impl TransferData { + pub fn data_type(&self) -> DataType { + match self { + Self::Svg(_) => DataType::Svg, + Self::Raster { format, .. } => DataType::Raster(*format), + Self::Document(_) => DataType::Gdd, + Self::LegacyDocument(_) => DataType::GraphiteLegacy, + Self::Lut(_) => DataType::Lut, + Self::Text(_) | Self::ApplicationData(_) => DataType::Text, + Self::Other(_) => DataType::Unknown, + } + } + + pub fn into_bytes(self) -> Vec { + match self { + Self::Text(text) | Self::Svg(text) | Self::LegacyDocument(text) => text.into_bytes(), + Self::Raster { data, .. } | Self::Document(data) | Self::Lut(data) | Self::Other(data) => data, + Self::ApplicationData(_) => DataTransferItem::from(self).into_raw().map(|(_, data)| data.into_bytes()).unwrap_or_default(), + } + } +} + +pub enum RawData { + Text(String), + Bytes(Vec), +} + +impl RawData { + pub fn into_string(self) -> Option { + match self { + Self::Text(text) => Some(text), + Self::Bytes(bytes) => String::from_utf8(bytes).ok(), + } + } + + pub fn into_bytes(self) -> Vec { + match self { + Self::Text(text) => text.into_bytes(), + Self::Bytes(bytes) => bytes, + } + } +} + +impl DataTransferItem { + pub fn from_raw(data_type: DataType, data: RawData) -> Option { + let data = match data_type { + DataType::Text => { + let text = data.into_string()?; + let items = text + .strip_prefix(GRAPHITE_PREFIX) + .and_then(|json| serde_json::from_str(json).inspect_err(|error| log::error!("Failed to deserialize application data: {error}")).ok()); + match items { + Some(items) => TransferData::ApplicationData(items), + None => TransferData::Text(text), + } + } + DataType::Svg => TransferData::Svg(data.into_string()?), + DataType::Raster(format) => TransferData::Raster { format, data: data.into_bytes() }, + _ => return None, + }; + Some(data.into()) + } + + pub fn from_file(path: PathBuf, mime: Option<&str>, data: Vec) -> Self { + let text = |data: Vec, into: fn(String) -> TransferData| String::from_utf8(data).map_or_else(|error| TransferData::Other(error.into_bytes()), into); + let data = match DataType::detect(&data, mime, Some(&path)) { + DataType::GraphiteLegacy => text(data, TransferData::LegacyDocument), + DataType::Gdd => TransferData::Document(data), + DataType::Svg => text(data, TransferData::Svg), + DataType::Raster(format) => TransferData::Raster { format, data }, + DataType::Lut => TransferData::Lut(data), + DataType::Text | DataType::Unknown => TransferData::Other(data), + }; + Self { data, path: Some(path) } + } + + pub fn into_raw(self) -> Option<(DataType, RawData)> { + match self.data { + TransferData::Text(text) => Some((DataType::Text, RawData::Text(text))), + TransferData::ApplicationData(items) => match serde_json::to_string(&items) { + Ok(json) => Some((DataType::Text, RawData::Text(format!("{GRAPHITE_PREFIX}{json}")))), + Err(error) => { + log::error!("Failed to serialize application data: {error}"); + None + } + }, + TransferData::Svg(svg) => Some((DataType::Svg, RawData::Text(svg))), + TransferData::Raster { format, data } => Some((DataType::Raster(format), RawData::Bytes(data))), + TransferData::Document(_) | TransferData::LegacyDocument(_) | TransferData::Lut(_) | TransferData::Other(_) => None, + } + } +} + +impl From for DataTransferItem { + fn from(data: TransferData) -> Self { + Self { data, path: None } + } +} + +impl From for DataTransfer { + fn from(item: DataTransferItem) -> Self { + Self(vec![item]) + } +} + +impl From for DataTransfer { + fn from(data: TransferData) -> Self { + DataTransferItem::from(data).into() + } +} + +impl FromIterator for DataTransfer { + fn from_iter>(iter: T) -> Self { + Self(iter.into_iter().collect()) + } +} + +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum ApplicationDataItem { + Layer(ApplicationDataLayer), + Nodes(Vec<(NodeId, NodeTemplate)>), + ChainNodes(Vec), + Vector(Vec), + Resource(ApplicationDataResource), + Definition(Box), +} + +pub type ApplicationDataVector = (LayerNodeIdentifier, VectorModification, DAffine2); + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ApplicationDataLayer { + pub nodes: Vec<(NodeId, NodeTemplate)>, + pub visible: bool, + pub locked: bool, + pub collapsed: bool, +} + +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ApplicationDataResource { + pub id: ResourceId, + pub hash: Option, + pub sources: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(PartialEq, Clone)] +pub struct ResourceData(pub Vec); +impl std::fmt::Debug for ResourceData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResourceData").field("len", &self.0.len()).finish() + } +} +impl serde::Serialize for ResourceData { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&BASE64.encode(&self.0)) + } +} +impl<'de> serde::Deserialize<'de> for ResourceData { + fn deserialize>(deserializer: D) -> Result { + let encoded = String::deserialize(deserializer)?; + Ok(Self(BASE64.decode(&encoded).map_err(serde::de::Error::custom)?)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -220,18 +420,18 @@ mod tests { #[test] fn data_type_detect() { - let detect = |mime_type: &str, path: &str| DataType::detect(&[], mime_type, Some(Path::new(path))); - assert_eq!(detect("", "photo.JPEG"), DataType::Raster(ImageFormat::Jpeg)); - assert_eq!(detect("image/svg+xml", ""), DataType::Svg); - assert_eq!(detect("application/graphite+json", ""), DataType::GraphiteLegacy); - assert_eq!(detect("image/png", "document.gdd"), DataType::Raster(ImageFormat::Png)); - assert_eq!(detect("", "grade.CUBE"), DataType::Lut); - assert_eq!(detect("application/vnd.iccprofile", "profile.icm"), DataType::Lut); - assert_eq!(detect("text/csv", "table.csv"), DataType::Unknown); - assert_eq!(DataType::detect(&[], "", None), DataType::Unknown); + let detect = |mime: Option<&str>, path: &str| DataType::detect(&[], mime, Some(Path::new(path))); + assert_eq!(detect(None, "photo.JPEG"), DataType::Raster(ImageFormat::Jpeg)); + assert_eq!(detect(Some("image/svg+xml"), ""), DataType::Svg); + assert_eq!(detect(Some("application/graphite+json"), ""), DataType::GraphiteLegacy); + assert_eq!(detect(Some("image/png"), "document.gdd"), DataType::Raster(ImageFormat::Png)); + assert_eq!(detect(None, "grade.CUBE"), DataType::Lut); + assert_eq!(detect(Some("application/vnd.iccprofile"), "profile.icm"), DataType::Lut); + assert_eq!(detect(Some("text/csv"), "table.csv"), DataType::Unknown); + assert_eq!(DataType::detect(&[], None, None), DataType::Unknown); let png = Image::new(1, 1, Color::WHITE).to_png(); - assert_eq!(DataType::detect(&png, "image/svg+xml", Some(Path::new("drawing.svg"))), DataType::Raster(ImageFormat::Png)); + assert_eq!(DataType::detect(&png, Some("image/svg+xml"), Some(Path::new("drawing.svg"))), DataType::Raster(ImageFormat::Png)); } #[test] @@ -248,4 +448,58 @@ mod tests { let filter = FileFilter::from(TypeFilter::lut()); assert!(filter.extensions.iter().any(|extension| extension == "cube") && filter.mime_types.is_empty()); } + + #[test] + fn application_data_travels_as_prefixed_text() { + let (data_type, data) = DataTransferItem::from(TransferData::ApplicationData(Vec::new())).into_raw().unwrap(); + assert_eq!(data_type, DataType::Text); + assert_eq!(data.into_string().as_deref(), Some("graphite: []")); + + let item = |text: &[u8]| DataTransferItem::from_raw(DataType::Text, RawData::Bytes(text.to_vec())).map(|item| item.data); + assert_eq!(item(b"graphite: []"), Some(TransferData::ApplicationData(Vec::new()))); + assert_eq!(item(b"graphite"), Some(TransferData::Text("graphite".to_string()))); + assert_eq!(item(b"graphite: not a payload"), Some(TransferData::Text("graphite: not a payload".to_string()))); + } + + #[test] + fn raw_data_lifts_by_mime_type() { + let item = |mime: &str, data: &[u8]| DataTransferItem::from_raw(DataType::from_mime(mime), RawData::Bytes(data.to_vec())).map(|item| item.data); + assert_eq!(item(SVG_MIME, b""), Some(TransferData::Svg("".to_string()))); + assert_eq!( + item("image/png", &[1, 2]), + Some(TransferData::Raster { + format: ImageFormat::Png, + data: vec![1, 2] + }) + ); + assert_eq!(item("text/html", b"bold"), None); + } + + #[test] + fn files_are_detected_by_content_first() { + let png = Image::new(1, 1, Color::WHITE).to_png(); + let file = DataTransferItem::from_file("photo.jpg".into(), Some("image/jpeg"), png.clone()); + assert_eq!(file.data, TransferData::Raster { format: ImageFormat::Png, data: png }); + assert_eq!(file.path, Some(PathBuf::from("photo.jpg"))); + + assert_eq!( + DataTransferItem::from_file("document.gdd".into(), None, b"PK\x03\x04".to_vec()).data, + TransferData::Document(b"PK\x03\x04".to_vec()) + ); + assert_eq!(DataTransferItem::from_file("grade.cube".into(), None, b"LUT".to_vec()).data, TransferData::Lut(b"LUT".to_vec())); + assert_eq!(DataTransferItem::from_file("notes.txt".into(), None, b"notes".to_vec()).data, TransferData::Other(b"notes".to_vec())); + } + + #[test] + fn data_transfer_round_trips_through_serde() { + let transfer: DataTransfer = [ + DataTransferItem::from(TransferData::ApplicationData(Vec::new())), + DataTransferItem::from_file("drawing.svg".into(), None, b"".to_vec()), + ] + .into_iter() + .collect(); + + let json = serde_json::to_string(&transfer).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), transfer); + } } diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 27cbdaad659..8db38079910 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -169,18 +169,18 @@ pub enum PortfolioMessage { ignore_hash: bool, }, SubmitEyedropperPreviewRender, - PollNodeGraphEvaluation, SubmitThumbnailsInView, + SubmitClipboardSvg { + id: u64, + }, SubmitInspectContextOverrides { overrides: graphene_std::ContextOverrides, }, + PollNodeGraphEvaluation, ToggleResetNodesToDefinitionsOnOpen, ToggleRulers, UpdateDocumentWidgets, UpdateOpenDocumentsList, - RequestSvgTextCopy { - graphite_json: String, - }, } /// Clone helper for non-serializable payloads: a cloned completion message carries none. diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 1b38d1f7159..c23f9f0f159 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -149,6 +149,7 @@ impl MessageHandler> for Portfolio } PortfolioMessage::Sync(message) => { let context = SyncMessageContext { + environment, documents: &mut self.documents, active_document_id: self.active_document_id, resource_storage, @@ -1127,6 +1128,20 @@ impl MessageHandler> for Portfolio self.executor.submit_thumbnails_in_view(document, document_id); } } + PortfolioMessage::SubmitClipboardSvg { id } => { + let layers = self.active_document().map(|document| { + let selected = document.partition_selection().layers; + let mut layers = document + .metadata() + .all_layers() + .filter(|layer| selected.contains(layer)) + .map(|layer| layer.to_node()) + .collect::>(); + layers.reverse(); + layers + }); + responses.add(self.executor.submit_clipboard_svg(self.active_document_id, id, layers.unwrap_or_default())); + } PortfolioMessage::ToggleResetNodesToDefinitionsOnOpen => { self.reset_node_definitions_on_open = !self.reset_node_definitions_on_open; responses.add(MenuBarMessage::SendLayout); @@ -1157,21 +1172,6 @@ impl MessageHandler> for Portfolio responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout); } } - PortfolioMessage::RequestSvgTextCopy { graphite_json } => { - if let Some(active_document) = self.active_document() { - // Bottom layer first, since SVG paints later elements on top - let selected_nodes: Vec = active_document - .network_interface - .shallowest_unique_layers_sorted(&[]) - .iter() - .rev() - .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/portfolio/sync/sync_message_handler.rs b/editor/src/messages/portfolio/sync/sync_message_handler.rs index a77082c5f75..bf409c82727 100644 --- a/editor/src/messages/portfolio/sync/sync_message_handler.rs +++ b/editor/src/messages/portfolio/sync/sync_message_handler.rs @@ -1,9 +1,10 @@ use super::identity::{anonymous_name, user_color}; -use crate::application::generate_uuid; +use crate::application::{Environment, generate_uuid}; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::DocumentMessageHandler; use crate::messages::portfolio::document::node_graph::utility_types::FrontendRemoteCursor; use crate::messages::portfolio::document::utility_types::network_interface::TransactionStatus; +use crate::messages::portfolio::ingest::utility_types::TransferData; use crate::messages::portfolio::utility_types::PanelType; use crate::messages::prelude::*; use crate::messages::resource_storage::ResourcesHandle; @@ -24,6 +25,7 @@ const APPLY_RETRY_MS: f64 = 1_000.; #[derive(ExtractField)] pub struct SyncMessageContext<'a> { + pub environment: Environment, pub documents: &'a mut HashMap, pub active_document_id: Option, pub resource_storage: &'a ResourceStorageMessageHandler, @@ -60,6 +62,7 @@ pub struct SyncMessageHandler { impl MessageHandler> for SyncMessageHandler { fn process_message(&mut self, message: SyncMessage, responses: &mut VecDeque, context: SyncMessageContext) { let SyncMessageContext { + environment, documents, active_document_id, resource_storage, @@ -88,7 +91,7 @@ impl MessageHandler> for SyncMessageHandler } document.stage_name_attribute(); let Some(gdd) = document.storage_mut() else { return }; - self.host(document_id, gdd, preferences, responses); + self.host(document_id, gdd, preferences, environment.url, responses); } SyncMessage::Join { token } => { let token = token.trim(); @@ -156,7 +159,7 @@ impl MessageHandler> for SyncMessageHandler refresh_session_views(responses); return; } - self.host(document_id, gdd, preferences, responses); + self.host(document_id, gdd, preferences, environment.url, responses); } SyncMessage::Disconnected { document_id, generation } => { if !self.is_current(document_id, generation) { @@ -171,7 +174,7 @@ impl MessageHandler> for SyncMessageHandler refresh_session_views(responses); } SyncMessage::RefreshPanel => { - let layout = collaboration_panel_layout(documents, active_document_id, preferences); + let layout = collaboration_panel_layout(documents, active_document_id, preferences, environment.url); responses.add(LayoutMessage::SendLayout { layout, layout_target: LayoutTarget::CollaborationPanel, @@ -223,9 +226,11 @@ impl MessageHandler> for SyncMessageHandler } impl SyncMessageHandler { - fn host(&mut self, document_id: DocumentId, gdd: &mut GddV1, preferences: &PreferencesMessageHandler, responses: &mut VecDeque) { + fn host(&mut self, document_id: DocumentId, gdd: &mut GddV1, preferences: &PreferencesMessageHandler, url: &str, responses: &mut VecDeque) { let Some(token) = self.connect_document(document_id, gdd, preferences, responses) else { return }; - responses.add(FrontendMessage::TriggerSessionLinkCopy { token: token.to_string() }); + responses.add(ClipboardMessage::Write { + content: TransferData::Text(session_link(url, token)).into(), + }); responses.add(WorkspaceMessage::FocusPanel { panel_type: PanelType::Collaboration }); } @@ -534,7 +539,11 @@ fn refresh_session_views(responses: &mut VecDeque) { responses.add(SyncMessage::RefreshPanel); } -fn collaboration_panel_layout(documents: &HashMap, active_document_id: Option, preferences: &PreferencesMessageHandler) -> Layout { +fn session_link(url: &str, token: SessionToken) -> String { + format!("{url}?session={token}") +} + +fn collaboration_panel_layout(documents: &HashMap, active_document_id: Option, preferences: &PreferencesMessageHandler, url: &str) -> Layout { let heading = |text: &str| LayoutGroup::row(vec![TextLabel::new(text).bold(true).widget_instance()]); let note = |text: &str| LayoutGroup::row(vec![TextLabel::new(text).multiline(true).widget_instance()]); let user_name = preferences.user_name.as_str(); @@ -611,8 +620,9 @@ fn collaboration_panel_layout(documents: &HashMap { - let token = SessionToken::for_document(gdd.manifest().document_id).to_string(); - let copy_link_token = token.clone(); + let token = SessionToken::for_document(gdd.manifest().document_id); + let link = session_link(url, token); + let token = token.to_string(); let copy_token = token.clone(); ( "Live session", @@ -620,7 +630,12 @@ fn collaboration_panel_layout(documents: &HashMap bool { #[cfg(test)] mod tests { use super::*; + use crate::application::DEFAULT_ENVIRONMENT; #[test] fn a_second_join_does_not_drop_the_first() { @@ -806,6 +827,7 @@ mod tests { let mut documents = HashMap::new(); for byte in [1, 2] { let context = SyncMessageContext { + environment: DEFAULT_ENVIRONMENT, documents: &mut documents, active_document_id: None, resource_storage: &resource_storage, diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 52ea6892d97..dcce1fe2ab4 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -4,7 +4,6 @@ use crate::consts::{ COLOR_OVERLAY_BLUE, COLOR_OVERLAY_BLUE_05, COLOR_OVERLAY_GRAY, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_GREEN_25, COLOR_OVERLAY_RED, COLOR_OVERLAY_RED_25, DEFAULT_STROKE_WIDTH, DOUBLE_CLICK_MILLISECONDS, DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD, DRAG_THRESHOLD, DRILL_THROUGH_THRESHOLD, SEGMENT_INSERTION_DISTANCE, SEGMENT_OVERLAY_SIZE, SELECTION_THRESHOLD, }; -use crate::messages::clipboard::utility_types::{ClipboardItem, ClipboardVectorEntry}; 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_network_node_type; @@ -13,6 +12,7 @@ use crate::messages::portfolio::document::overlays::utility_types::{DrawHandles, use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier}; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::portfolio::document::utility_types::transformation::Axis; +use crate::messages::portfolio::ingest::utility_types::{ApplicationDataItem, ApplicationDataVector}; use crate::messages::preferences::SelectionMode; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::graph_modification_utils; @@ -146,7 +146,7 @@ pub enum PathToolMessage { Cut, Paste { #[cfg_attr(feature = "wasm", tsify(type = "unknown"))] - paths: Vec, + paths: Vec, }, DeleteSelected, Duplicate, @@ -2838,7 +2838,7 @@ impl Fsm for PathToolFsmState { } responses.add(ClipboardMessage::WriteItems { - items: vec![ClipboardItem::Vector(buffer)], + items: vec![ApplicationDataItem::Vector(buffer)], }); PathToolFsmState::Ready @@ -4185,15 +4185,12 @@ mod test_path_tool { // Copy every point and paste them back into the same layer editor.select_tool(ToolType::Path).await; editor.handle_message(PathToolMessage::SelectAll).await; - let (_, clipboard) = editor + let clipboard = editor .copy_to_clipboard(ClipboardMessage::CopyLayers) .await .expect("copying points should write a payload to the clipboard"); - editor - .handle_message(ClipboardMessage::ReadClipboard { - content: ClipboardContentRaw::Text(clipboard), - }) - .await; + assert!(clipboard.0.iter().all(|item| matches!(item.data, TransferData::ApplicationData(_))), "{clipboard:?}"); + editor.paste_from_clipboard(clipboard).await; // Each curve now appears a second time under a new ID let control_points = |segment: kurbo::PathSeg| match segment { diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 8b97e713bb6..9f0abc40389 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -83,7 +83,7 @@ pub enum NodeGraphUpdate { EyedropperPreview(Raster), Thumbnails { responses: VecDeque, document_id: DocumentId }, InspectResult(Box), - SvgTextCopyClipboard { svg_string: Option, graphite_json: String }, + ClipboardSvg { id: u64, svg: Option }, } #[derive(Debug)] @@ -192,6 +192,15 @@ impl NodeGraphExecutor { self.sender.send(GraphRuntimeRequest::InspectContextOverrides(overrides)).expect("Failed to send the context overrides"); } + pub fn submit_clipboard_svg(&self, document_id: Option, id: u64, layers: Vec) -> Message { + if document_id.is_none() || self.node_graph_document != document_id { + return ClipboardMessage::Rendered { id, svg: None }.into(); + } + + self.sender.send(GraphRuntimeRequest::ClipboardSvg { id, layers }).expect("Failed to send runtime request"); + Message::NoOp + } + pub fn submit_thumbnails_in_view(&self, document: &DocumentMessageHandler, document_id: DocumentId) { // The runtime renders thumbnails from the network it has, which may still be another document's if self.node_graph_document != Some(document_id) { @@ -665,9 +674,7 @@ impl NodeGraphExecutor { Self::receive_thumbnails(document, thumbnails, responses); } } - NodeGraphUpdate::SvgTextCopyClipboard { svg_string, graphite_json } => { - responses.add(FrontendMessage::TriggerClipboardSvgAndJsonWrite { svg_string, graphite_json }); - } + NodeGraphUpdate::ClipboardSvg { id, svg } => responses.add(ClipboardMessage::Rendered { id, svg }), } } @@ -686,6 +693,7 @@ impl NodeGraphExecutor { } if thumbnails_document == document_id => { Self::receive_thumbnails(document, thumbnails, responses); } + NodeGraphUpdate::ClipboardSvg { id, svg } => responses.add(ClipboardMessage::Rendered { id, svg }), _ => {} } } @@ -1040,12 +1048,6 @@ impl NodeGraphExecutor { } } } - - pub fn copy_svg_clipboard(&self, graphite_json: String, selected_nodes: Vec) { - self.sender - .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 3a205520cac..231403516bc 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -78,14 +78,10 @@ pub struct NodeRuntime { pub enum GraphRuntimeRequest { GraphUpdate(GraphUpdate), ExecutionRequest(ExecutionRequest), - ThumbnailsInView { - layers: HashSet, - document_id: DocumentId, - }, - /// The full path from the root network to the node the Data panel inspects, or empty for none. + ThumbnailsInView { layers: HashSet, document_id: DocumentId }, Inspect(Vec), InspectContextOverrides(ContextOverrides), - CopySvgTextClipboard(String, Vec), + ClipboardSvg { id: u64, layers: Vec }, } #[derive(Debug, serde::Serialize, serde::Deserialize)] @@ -196,9 +192,9 @@ impl NodeRuntime { inspect_state.overrides_changed = true; } } - GraphRuntimeRequest::CopySvgTextClipboard(graphite_json, selected_node_ids) => { - let svg_string = self.old_graph.as_ref().and_then(|network| thumbnails::layers_svg(&selected_node_ids, network, &self.executor)); - self.sender.send(NodeGraphUpdate::SvgTextCopyClipboard { svg_string, graphite_json }).expect("Failed to send response"); + GraphRuntimeRequest::ClipboardSvg { id, layers } => { + let svg = self.old_graph.as_ref().and_then(|network| thumbnails::layers_svg(&layers, network, &self.executor)); + self.sender.send(NodeGraphUpdate::ClipboardSvg { id, svg }).expect("Failed to send response"); } } } diff --git a/editor/src/test_utils.rs b/editor/src/test_utils.rs index b912e2f08cb..194954c2ebe 100644 --- a/editor/src/test_utils.rs +++ b/editor/src/test_utils.rs @@ -4,7 +4,7 @@ use crate::messages::frontend::utility_types::RenderStatus; use crate::messages::input_mapper::utility_types::keyboard::ModifierKeys; use crate::messages::input_mapper::utility_types::pointer::{EditorPointerState, MouseKeys, ViewportPosition}; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; -use crate::messages::portfolio::ingest::utility_types::IngestAction; +use crate::messages::portfolio::ingest::utility_types::{DataTransfer, IngestAction, TransferData}; use crate::messages::prelude::*; use crate::messages::tool::tool_messages::tool_prelude::Key; use crate::messages::tool::utility_types::ToolType; @@ -113,8 +113,8 @@ impl EditorTestUtils { Err(TestGraphError::Other(format!("eval_graph_until_finished exceeded {MAX_ITERATIONS} graph executions; stopping"))) } - /// Handles a message that copies to the clipboard, returning the SVG and the payload written once the runtime has rendered the SVG. - pub async fn copy_to_clipboard(&mut self, message: impl Into) -> Option<(Option, String)> { + /// Handles a message that copies to the clipboard, returning what was last written once the runtime has rendered the copied layers. + pub async fn copy_to_clipboard(&mut self, message: impl Into) -> Option { if let Err(e) = self.eval_graph_until_finished().await { panic!("Failed to evaluate graph: {e}"); } @@ -124,10 +124,17 @@ impl EditorTestUtils { self.runtime.run().await; frontend_messages.extend(self.dispatch(Message::Wake)); - frontend_messages.into_iter().find_map(|message| match message { - FrontendMessage::TriggerClipboardSvgAndJsonWrite { svg_string, graphite_json } => Some((svg_string, graphite_json)), - _ => None, - }) + frontend_messages + .into_iter() + .filter_map(|message| match message { + FrontendMessage::TriggerClipboardWrite { content } => Some(content), + _ => None, + }) + .last() + } + + pub async fn paste_from_clipboard(&mut self, content: DataTransfer) -> Vec { + self.handle_message(IngestMessage::Ingest { content, action: IngestAction::Paste }).await } pub async fn handle_message(&mut self, message: impl Into) -> Vec { @@ -333,10 +340,12 @@ impl EditorTestUtils { pub async fn create_raster_image(&mut self, image: graphene_std::raster::Image, mouse: Option<(f64, f64)>) { self.handle_message(IngestMessage::Ingest { - data: image.to_png(), + content: TransferData::Raster { + format: image::ImageFormat::Png, + data: image.to_png(), + } + .into(), action: mouse.map_or(IngestAction::Paste, |mouse| IngestAction::DropOnCanvas { mouse }), - mime_type: String::new(), - path: None, }) .await; } @@ -529,11 +538,11 @@ pub mod test_prelude { pub use super::FrontendMessageTestUtils; pub use crate::application::Editor; pub use crate::float_eq; - pub use crate::messages::clipboard::utility_types::ClipboardContentRaw; pub use crate::messages::input_mapper::utility_types::keyboard::{Key, ModifierKeys}; pub use crate::messages::input_mapper::utility_types::pointer::MouseKeys; pub use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; pub use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; + pub use crate::messages::portfolio::ingest::utility_types::{DataTransfer, DataTransferItem, TransferData}; pub use crate::messages::prelude::*; pub use crate::messages::tool::common_functionality::graph_modification_utils::{NodeGraphLayer, is_layer_fed_by_node_of_name}; pub use crate::messages::tool::utility_types::ToolType; diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index b2d7a1f7d90..d1edcc639fb 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -15,7 +15,6 @@ import type { SubscriptionsRouter } from "/src/subscriptions-router"; import type { MessageBody } from "/src/subscriptions-router"; import { fillChoiceColor, createSRgba8, sRgba8FromCSS } from "/src/utility-functions/colors"; - import { pasteFile } from "/src/utility-functions/files"; import { cleanupInputField } from "/src/utility-functions/input"; import { textInputCleanup } from "/src/utility-functions/keyboard-entry"; import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization"; @@ -150,7 +149,7 @@ e.preventDefault(); - Array.from(e.dataTransfer.items).forEach(async (item) => await pasteFile(item, editor, mouse)); + editor.ingest(e.dataTransfer, mouse?.[0], mouse?.[1]); } function panCanvasX(newValue: number) { diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte index 02cb6accd01..935c5fc6e36 100644 --- a/frontend/src/components/panels/Layers.svelte +++ b/frontend/src/components/panels/Layers.svelte @@ -13,7 +13,6 @@ import type { PortfolioStore } from "/src/stores/portfolio"; import type { TooltipStore } from "/src/stores/tooltip"; import { createAutoScroller, speedWhileAimingAt } from "/src/utility-functions/auto-scroll"; - import { pasteFile } from "/src/utility-functions/files"; import { targetIsTextField } from "/src/utility-functions/input"; import { operatingSystem } from "/src/utility-functions/platform"; import type { EditorWrapper, LayerPanelChainInsertion, LayerPanelChainNode, LayerPanelEntry, LayerStructureEntry } from "/wrapper/pkg/graphite_wasm_wrapper"; @@ -1091,7 +1090,7 @@ e.preventDefault(); - Array.from(e.dataTransfer.items).forEach(async (item) => await pasteFile(item, editor, undefined, insertParentId, insertIndex)); + editor.ingest(e.dataTransfer, undefined, undefined, insertParentId, insertIndex); draggingData = undefined; fakeHighlightOfNotYetSelectedLayerBeingDragged = undefined; diff --git a/frontend/src/components/panels/Welcome.svelte b/frontend/src/components/panels/Welcome.svelte index 9c5cd4af1c3..cec18661c7b 100644 --- a/frontend/src/components/panels/Welcome.svelte +++ b/frontend/src/components/panels/Welcome.svelte @@ -6,7 +6,6 @@ import TextLabel from "/src/components/widgets/labels/TextLabel.svelte"; import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte"; import { welcomeScreenButtonsLayout } from "/src/stores/portfolio"; - import { pasteFile } from "/src/utility-functions/files"; import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; const editor = getContext("editor"); @@ -16,7 +15,7 @@ e.preventDefault(); - Array.from(e.dataTransfer.items).forEach(async (item) => await pasteFile(item, editor)); + editor.ingest(e.dataTransfer); } diff --git a/frontend/src/managers/clipboard.ts b/frontend/src/managers/clipboard.ts index 944e3cd9f12..bdcb72426a9 100644 --- a/frontend/src/managers/clipboard.ts +++ b/frontend/src/managers/clipboard.ts @@ -12,18 +12,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("TriggerSessionLinkCopy", (data) => { - const url = new URL(window.location.href); - url.hash = ""; - url.search = new URLSearchParams({ session: data.token }).toString(); - navigator.clipboard?.writeText?.(url.toString()); - }); - subscriptions.subscribeFrontendMessage("TriggerSelectionRead", async (data) => { const content = readAtCaret(data.cut); // A text field with nothing selected has nothing to copy, rather than leaving the shortcut to act on the selected layers @@ -35,31 +23,12 @@ export function createClipboardManager(subscriptions: SubscriptionsRouter, edito subscriptions.subscribeFrontendMessage("TriggerSelectionWrite", async (data) => { insertAtCaret(data.content); }); - - subscriptions.subscribeFrontendMessage("TriggerClipboardSvgAndJsonWrite", (data) => { - const writeText = () => navigator.clipboard?.writeText?.(data.graphiteJson); - - // Adopted from https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem#browser_compatibility - const svgSupported = typeof ClipboardItem !== "undefined" && ClipboardItem.supports?.("image/svg+xml"); - if (svgSupported && data.svgString !== undefined && navigator.clipboard?.write) { - const item = new ClipboardItem({ - "image/svg+xml": data.svgString, - "text/plain": data.graphiteJson, - }); - navigator.clipboard.write([item]).catch(writeText); - } else { - writeText(); - } - }); } export function destroyClipboardManager() { const subscriptions = subscriptionsRouter; if (!subscriptions) return; - subscriptions.unsubscribeFrontendMessage("TriggerClipboardWrite"); - subscriptions.unsubscribeFrontendMessage("TriggerClipboardSvgAndJsonWrite"); - subscriptions.unsubscribeFrontendMessage("TriggerSessionLinkCopy"); subscriptions.unsubscribeFrontendMessage("TriggerSelectionRead"); subscriptions.unsubscribeFrontendMessage("TriggerSelectionWrite"); } diff --git a/frontend/src/managers/input.ts b/frontend/src/managers/input.ts index fb4b5d91830..742296aa91f 100644 --- a/frontend/src/managers/input.ts +++ b/frontend/src/managers/input.ts @@ -3,7 +3,6 @@ import type { DocumentStore } from "/src/stores/document"; import { fullscreenModeChanged } from "/src/stores/fullscreen"; import type { PortfolioStore } from "/src/stores/portfolio"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; -import { triggerClipboardRead } from "/src/utility-functions/clipboard"; import { onBeforeUnload, onKeyUp, @@ -69,10 +68,6 @@ export function createInputManager(subscriptions: SubscriptionsRouter, editor: E portfolioStore = portfolio; documentStore = doc; - subscriptions.subscribeFrontendMessage("TriggerClipboardRead", () => { - triggerClipboardRead(editor); - }); - subscriptions.subscribeFrontendMessage("WindowPointerLockMove", (data) => { // Desktop app only: dispatch custom pointer lock movement events const event = new CustomEvent("pointerlockmove", { detail: { x: data.position[0], y: data.position[1] } }); @@ -94,7 +89,6 @@ export function destroyInputManager() { const subscriptions = subscriptionsRouter; if (!subscriptions) return; - subscriptions.unsubscribeFrontendMessage("TriggerClipboardRead"); subscriptions.unsubscribeFrontendMessage("WindowPointerLockMove"); // Remove event bindings after the lifetime of the application (or on hot-module replacement during development) diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 3e59b3c9333..82c1dda8304 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -82,7 +82,7 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: try { const url = new URL(`demo-artwork/${data.filename}`, document.location.href); const response = await fetch(url); - editor.ingestPicked(data.filename, "", await response.bytes(), "OpenDemo"); + editor.ingestPicked(data.filename, "", await response.bytes(), "OpenUnique"); } catch { // Needs to be delayed until the end of the current call stack so the existing demo artwork dialog can be closed first, otherwise this dialog won't show setTimeout(() => { diff --git a/frontend/src/utility-functions/clipboard.ts b/frontend/src/utility-functions/clipboard.ts index 466756461bd..edfe5d4c2a4 100644 --- a/frontend/src/utility-functions/clipboard.ts +++ b/frontend/src/utility-functions/clipboard.ts @@ -1,6 +1,3 @@ -import { stripIndents } from "/src/utility-functions/strip-indents"; -import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; - export function readAtCaret(cut: boolean): string | undefined { const element = window.document.activeElement; @@ -92,75 +89,3 @@ export function insertAtCaret(text: string) { element.dispatchEvent(new Event("input", { bubbles: true })); } - -export async function triggerClipboardRead(editor: EditorWrapper) { - // In the try block, attempt to read from the Clipboard API, which may not have permission and may not be supported in all browsers - // In the catch block, explain to the user why the paste failed and how to fix or work around the problem - try { - // Attempt to check if the clipboard permission is denied, and throw an error if that is the case - // In Firefox, the `clipboard-read` permission isn't supported, so attempting to query it throws an error - // In Safari, the entire Permissions API isn't supported, so the query never occurs and this block is skipped without an error and we assume we might have permission - const permission = await navigator.permissions?.query({ name: "clipboard-read" }); - if (permission?.state === "denied") throw new Error("Permission denied"); - - // Read the clipboard contents if the Clipboard API is available - const clipboardItems = await navigator.clipboard.read(); - if (!clipboardItems) throw new Error("Clipboard API unsupported"); - - // Read any layer data or images from the clipboard - const success = await Promise.any( - Array.from(clipboardItems).map(async (item) => { - // Read plain text and, if it is a layer, pass it to the editor - if (item.types.includes("text/plain")) { - const blob = await item.getType("text/plain"); - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === "string") editor.pasteText(reader.result); - }; - reader.readAsText(blob); - return true; - } - - // Read an image from the clipboard and pass it to the editor to be loaded - const imageType = item.types.find((type) => type.startsWith("image/")); - - if (imageType) { - const blob = await item.getType(imageType); - editor.ingestFile(undefined, imageType, new Uint8Array(await blob.arrayBuffer())); - return true; - } - - // The API limits what kinds of data we can access, so we can get copied images and our text encodings of copied nodes, but not files (like - // .graphite or even image files). However, the user can paste those with Ctrl+V, which we recommend they in the error message that's shown to them. - return false; - }), - ); - - if (!success) throw new Error("No valid clipboard data"); - } catch (err) { - const unsupported = stripIndents` - This browser does not support reading from the clipboard. - Use the standard keyboard shortcut to paste instead. - `; - const denied = stripIndents` - The browser's clipboard permission has been denied. - - Open the browser's website settings (usually accessible - just left of the URL bar) to allow this permission. - `; - const nothing = stripIndents` - No valid clipboard data was found. You may have better - success pasting with the standard keyboard shortcut instead. - `; - - const matchMessage = { - "clipboard-read": unsupported, - "Clipboard API unsupported": unsupported, - "Permission denied": denied, - "No valid clipboard data": nothing, - }; - const message = Object.entries(matchMessage).find(([key]) => String(err).includes(key))?.[1] || String(err); - - editor.errorDialog("Cannot access clipboard", message); - } -} diff --git a/frontend/src/utility-functions/files.ts b/frontend/src/utility-functions/files.ts index b4690d930f0..24590fdf1be 100644 --- a/frontend/src/utility-functions/files.ts +++ b/frontend/src/utility-functions/files.ts @@ -1,4 +1,4 @@ -import type { EditorWrapper, FileFilter } from "/wrapper/pkg/graphite_wasm_wrapper"; +import type { FileFilter } from "/wrapper/pkg/graphite_wasm_wrapper"; export function downloadFileURL(filename: string, url: string) { const element = document.createElement("a"); @@ -80,13 +80,6 @@ export async function upload( } export type UploadResult = { filename: string; type: string; content: T }; -export async function pasteFile(item: DataTransferItem, editor: EditorWrapper, mouse?: [number, number], insertParentId?: bigint, insertIndex?: number) { - const file = item.getAsFile(); - if (!file) return; - - editor.ingestFile(file.name, file.type, await file.bytes(), mouse?.[0], mouse?.[1], insertParentId, insertIndex); -} - export function acceptStringFromFilters(filters: FileFilter[]): string { return filters.flatMap((filter) => [...filter.mimeTypes, ...filter.extensions.map((extension) => `.${extension}`)]).join(","); } diff --git a/frontend/src/utility-functions/input.ts b/frontend/src/utility-functions/input.ts index 7be6458e4bf..b31fd6754f3 100644 --- a/frontend/src/utility-functions/input.ts +++ b/frontend/src/utility-functions/input.ts @@ -2,7 +2,6 @@ import { get } from "svelte/store"; import type { DialogStore } from "/src/stores/dialog"; import { toggleFullscreen } from "/src/stores/fullscreen"; import type { PortfolioStore } from "/src/stores/portfolio"; -import { pasteFile } from "/src/utility-functions/files"; import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "/src/utility-functions/keyboard-entry"; import { operatingSystem } from "/src/utility-functions/platform"; import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; @@ -291,10 +290,7 @@ export function onPaste(e: ClipboardEvent, editor: EditorWrapper) { if (!dataTransfer || targetIsTextField(e.target || undefined)) return; e.preventDefault(); - Array.from(dataTransfer.items).forEach(async (item) => { - if (item.type === "text/plain") item.getAsString((text) => editor.pasteText(text)); - await pasteFile(item, editor); - }); + editor.ingest(dataTransfer); } // A spot that takes dropped files has canceled the event by the time it bubbles up to the window diff --git a/frontend/src/utility-functions/network.ts b/frontend/src/utility-functions/network.ts index a9b8fad8fe3..92b9ecaa57f 100644 --- a/frontend/src/utility-functions/network.ts +++ b/frontend/src/utility-functions/network.ts @@ -58,7 +58,7 @@ export async function loadDemoArtwork(editor: EditorWrapper) { const filename = url.pathname.split("/").pop() || "Untitled.graphite"; const content = await response.bytes(); - editor.ingestPicked(filename, "", content, "OpenDemo"); + editor.ingestPicked(filename, "", content, "OpenUnique"); history.replaceState("", "", `${window.location.pathname}${window.location.search}`); } catch { diff --git a/frontend/wrapper/Cargo.toml b/frontend/wrapper/Cargo.toml index 8675f032630..9ac9fde609b 100644 --- a/frontend/wrapper/Cargo.toml +++ b/frontend/wrapper/Cargo.toml @@ -36,7 +36,7 @@ serde-wasm-bindgen = { workspace = true } js-sys = { workspace = true } wasm-bindgen-futures = { workspace = true } wgpu = { workspace = true, optional = true } -web-sys = { workspace = true, features = ["IdleDeadline"] } +web-sys = { workspace = true, features = ["Blob", "BlobPropertyBag", "ClipboardItem", "DataTransfer", "DataTransferItem", "DataTransferItemList", "File", "IdleDeadline", "Location", "PermissionState", "PermissionStatus", "Permissions"] } serde_json = { workspace = true } spin = { workspace = true, optional = true } tsify = { workspace = true } diff --git a/frontend/wrapper/src/data_transfer.rs b/frontend/wrapper/src/data_transfer.rs new file mode 100644 index 00000000000..117effbbd44 --- /dev/null +++ b/frontend/wrapper/src/data_transfer.rs @@ -0,0 +1,139 @@ +use crate::helpers::wrapper; +use editor::messages::portfolio::ingest::utility_types::{DataTransfer, DataTransferItem, DataType, IngestAction, RawData}; +use editor::messages::prelude::*; +use js_sys::{Array, Object, Promise, Reflect, Uint8Array}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; +use web_sys::{Blob, BlobPropertyBag, ClipboardItem, PermissionState, PermissionStatus}; + +const READ_UNSUPPORTED: &str = "This browser does not support reading from the clipboard.\nUse the standard keyboard shortcut to paste instead."; +const READ_DENIED: &str = "The browser's clipboard permission has been denied.\n\nOpen the browser's website settings (usually accessible\njust left of the URL bar) to allow this permission."; +const READ_NOTHING: &str = "No valid clipboard data was found. You may have better\nsuccess pasting with the standard keyboard shortcut instead."; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(catch, js_namespace = ["navigator", "clipboard"], js_name = read)] + fn clipboard_read() -> Result; + #[wasm_bindgen(catch, js_namespace = ["navigator", "clipboard"], js_name = write)] + fn clipboard_write(items: &Array) -> Result; + #[wasm_bindgen(catch, js_namespace = ["navigator", "clipboard"], js_name = writeText)] + fn clipboard_write_text(text: &str) -> Result; + #[wasm_bindgen(catch, js_namespace = ClipboardItem, js_name = supports)] + fn clipboard_item_supports(mime: &str) -> Result; +} + +pub(crate) fn write_clipboard(content: DataTransfer) { + let representations: Vec<_> = content.0.into_iter().filter_map(DataTransferItem::into_raw).collect(); + let text = representations.iter().find_map(|(data_type, data)| match data { + RawData::Text(text) if *data_type == DataType::Text => Some(text.clone()), + _ => None, + }); + let write_text = move || { + if let Some(text) = text { + let _ = clipboard_write_text(&text); + } + }; + + let record = Object::new(); + for (data_type, data) in representations { + let Some(mime) = data_type.mime().filter(|mime| clipboard_item_supports(mime).unwrap_or(false)) else { + continue; + }; + let options = BlobPropertyBag::new(); + options.set_type(mime); + if let Ok(blob) = Blob::new_with_u8_array_sequence_and_options(&Array::of1(&Uint8Array::from(data.into_bytes().as_slice())), &options) { + let _ = Reflect::set(&record, &mime.into(), &blob); + } + } + + let write = match Object::keys(&record).length() { + 0 => None, + _ => ClipboardItem::new_with_record_from_str_to_blob_promise(&record) + .ok() + .and_then(|item| clipboard_write(&Array::of1(&item)).ok()), + }; + match write { + Some(write) => wasm_bindgen_futures::spawn_local(async move { + if JsFuture::from(write).await.is_err() { + write_text(); + } + }), + None => write_text(), + } +} + +pub(crate) fn read_clipboard() { + wasm_bindgen_futures::spawn_local(async { + let message: Message = match clipboard_content().await { + Ok(content) => IngestMessage::Ingest { content, action: IngestAction::Paste }.into(), + Err(description) => DialogMessage::DisplayDialogError { + title: "Cannot access clipboard".to_string(), + description, + } + .into(), + }; + wrapper(|wrapper| wrapper.dispatch(message)); + }); +} + +async fn clipboard_content() -> Result { + let permissions = web_sys::window().expect("No global `window` exists").navigator().permissions(); + if let Some(permissions) = permissions.ok().filter(|permissions| !permissions.is_undefined()) { + let descriptor = Object::new(); + let _ = Reflect::set(&descriptor, &"name".into(), &"clipboard-read".into()); + let status = match permissions.query(&descriptor) { + Ok(query) => JsFuture::from(query).await, + Err(error) => Err(error), + }; + match status.map(PermissionStatus::unchecked_from_js) { + Ok(status) if status.state() == PermissionState::Denied => return Err(READ_DENIED.to_string()), + Ok(_) => {} + Err(_) => return Err(READ_UNSUPPORTED.to_string()), + } + } + + let items = JsFuture::from(clipboard_read().map_err(|_| READ_UNSUPPORTED.to_string())?).await.map_err(describe)?; + let mut content = Vec::new(); + for item in Array::from(&items).iter().map(ClipboardItem::unchecked_from_js) { + for mime in item.types().iter().filter_map(|mime| mime.as_string()) { + let blob = JsFuture::from(item.get_type(&mime)).await.map_err(describe)?; + let data = JsFuture::from(Blob::unchecked_from_js(blob).array_buffer()).await.map_err(describe)?; + content.extend(DataTransferItem::from_raw(DataType::from_mime(&mime), RawData::Bytes(Uint8Array::new(&data).to_vec()))); + } + } + if content.is_empty() { + return Err(READ_NOTHING.to_string()); + } + Ok(DataTransfer(content)) +} + +fn describe(error: JsValue) -> String { + match error.dyn_into::() { + Ok(error) => error.to_string().into(), + Err(error) => format!("{error:?}"), + } +} + +pub(crate) fn ingest(data_transfer: &web_sys::DataTransfer, action: IngestAction) { + let items = data_transfer.items(); + let mut content = Vec::new(); + let mut files = Vec::new(); + for item in (0..items.length()).filter_map(|index| items.get(index)) { + let mime = item.type_(); + if item.kind() == "file" { + files.extend(item.get_as_file().ok().flatten().map(|file| (mime, file))); + } else if let Ok(text) = data_transfer.get_data(&mime) { + content.extend(DataTransferItem::from_raw(DataType::from_mime(&mime), RawData::Text(text))); + } + } + + wasm_bindgen_futures::spawn_local(async move { + for (mime, file) in files { + let Ok(data) = JsFuture::from(file.array_buffer()).await else { continue }; + let mime = Some(mime.as_str()).filter(|mime| !mime.is_empty()); + content.push(DataTransferItem::from_file(file.name().into(), mime, Uint8Array::new(&data).to_vec())); + } + let content = DataTransfer(content); + wrapper(|wrapper| wrapper.dispatch(IngestMessage::Ingest { content, action })); + }); +} diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index 3c8566e5af7..7b02955f76c 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -16,12 +16,12 @@ use wasm_bindgen::prelude::*; #[editor_commands] mod editor_commands { use crate::helpers::translate_key; - use editor::messages::clipboard::utility_types::ClipboardContentRaw; use editor::messages::input_mapper::utility_types::keyboard::ModifierKeys; use editor::messages::input_mapper::utility_types::pointer::{EditorPointerState, ScrollDelta}; use editor::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport; + use editor::messages::portfolio::ingest::utility_types::DataTransferItem; use editor::messages::portfolio::utility_types::PanelGroupId; use editor::messages::prelude::*; use editor::messages::tool::tool_messages::tool_prelude::{DroppedFile, WidgetId}; @@ -716,40 +716,9 @@ mod editor_commands { /// A file headed for a known action, picked in the dialog that `TriggerBrowse` opened fn ingest_picked(name: String, mime_type: String, data: Vec, action: Ts) -> Message { - IngestMessage::Ingest { - data, - action, - mime_type, - path: Some(PathBuf::from(name)), - } - .into() - } - - /// A file dropped on a panel or pasted, placed by the drop position or the layer slot it landed in - fn ingest_file(name: Option, mime_type: String, data: Vec, mouse_x: Option, mouse_y: Option, insert_parent_id: Option, insert_index: Option) -> Message { - let action = match (insert_parent_id.zip(insert_index), mouse_x.zip(mouse_y)) { - (Some((parent, insert_index)), _) => IngestAction::DropOnLayers { - parent: LayerNodeIdentifier::new_unchecked(NodeId(parent)), - insert_index, - }, - (None, Some(mouse)) => IngestAction::DropOnCanvas { mouse }, - (None, None) => IngestAction::Paste, - }; - IngestMessage::Ingest { - data, - action, - mime_type, - path: name.map(PathBuf::from), - } - .into() - } - - /// Paste from a serialized JSON representation - fn paste_text(data: String) -> Message { - ClipboardMessage::ReadClipboard { - content: ClipboardContentRaw::Text(data), - } - .into() + let mime = Some(mime_type.as_str()).filter(|mime| !mime.is_empty()); + let content = DataTransferItem::from_file(PathBuf::from(name), mime, data).into(); + IngestMessage::Ingest { content, action }.into() } /// Toggle visibility of a layer or node in the document network, as listed in the Layers panel diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 9228259f684..bd5210e63be 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -7,18 +7,24 @@ use crate::EDITOR; use crate::MESSAGE_BUFFER; #[cfg(all(feature = "native", target_family = "wasm"))] use crate::editor_commands::EditorCommand; -#[cfg(feature = "editor")] -use crate::helpers::render_image_data_to_canvases; #[cfg(not(feature = "native"))] use crate::helpers::run_node_runtime; #[cfg(any(not(feature = "native"), target_family = "wasm"))] use crate::helpers::wrapper; use crate::helpers::{request_animation_frame, set_timeout}; +#[cfg(not(feature = "native"))] +use crate::intercept_frontend_message::intercept_frontend_message; use crate::{EDITOR_HAS_CRASHED, FRONTEND_READY}; #[cfg(all(not(feature = "native"), target_family = "wasm"))] use editor::application::{Environment, Host, Platform}; +#[cfg(not(feature = "native"))] +use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +#[cfg(not(feature = "native"))] +use editor::messages::portfolio::ingest::utility_types::IngestAction; #[cfg(feature = "editor")] use editor::messages::prelude::*; +#[cfg(not(feature = "native"))] +use graph_craft::document::NodeId; #[cfg(feature = "editor")] use serde::Serialize; use std::cell::RefCell; @@ -63,6 +69,9 @@ impl EditorWrapper { _ => unreachable!(), }; + let location = web_sys::window().expect("No global `window` exists").location(); + let url = format!("{}{}", location.origin().unwrap_or_default(), location.pathname().unwrap_or_default()); + let storage: std::sync::Arc = match OpfsResourceStorage::load("resources").await { Ok(storage) => std::sync::Arc::new(storage), Err(error) => { @@ -77,7 +86,11 @@ impl EditorWrapper { .with_editor( EditorBuilder::new() .with_wake(crate::helpers::wake_editor) - .with_environment(Environment { platform: Platform::Web, host }) + .with_environment(Environment { + platform: Platform::Web, + host, + url: url.leak(), + }) .with_resource_storage(storage) .with_document_store(document_store), ) @@ -119,23 +132,13 @@ impl EditorWrapper { }); // Send each `FrontendMessage` to the JavaScript frontend - for message in frontend_messages.into_iter() { + for message in frontend_messages.into_iter().filter_map(intercept_frontend_message) { self.send_frontend_message_to_js(message); } } #[cfg(feature = "editor")] pub(crate) fn send_frontend_message_to_js(&self, message: FrontendMessage) { - if let FrontendMessage::UpdateImageData { - ref image_data, - ref canvas_ids_in_use, - } = message - { - let images = image_data.iter().map(|image| (image.id, image.width, image.height, image.pixels.as_slice())); - render_image_data_to_canvases(images, canvas_ids_in_use); - return; - } - let message_type = message.to_discriminant().local_name(); let serializer = serde_wasm_bindgen::Serializer::new().serialize_large_number_types_as_bigints(true); @@ -265,4 +268,21 @@ impl EditorWrapper { pub fn load_persisted_state(&self, _state: JsValue) { log::error!("loadPersistedState is unavailable on desktop; persistence is handled natively"); } + + #[cfg(not(feature = "native"))] + pub fn ingest(&self, data: &web_sys::DataTransfer, mouse_x: Option, mouse_y: Option, insert_parent_id: Option, insert_index: Option) { + let action = match (insert_parent_id.zip(insert_index), mouse_x.zip(mouse_y)) { + (Some((parent, insert_index)), _) => IngestAction::DropOnLayers { + parent: LayerNodeIdentifier::new_unchecked(NodeId(parent)), + insert_index, + }, + (None, Some(mouse)) => IngestAction::DropOnCanvas { mouse }, + (None, None) => IngestAction::Paste, + }; + crate::data_transfer::ingest(data, action); + } + #[cfg(feature = "native")] + pub fn ingest(&self, _data: JsValue, _mouse_x: Option, _mouse_y: Option, _insert_parent_id: Option, _insert_index: Option) { + log::error!("ingest is unavailable on desktop; drops and pastes are handled natively"); + } } diff --git a/frontend/wrapper/src/intercept_frontend_message.rs b/frontend/wrapper/src/intercept_frontend_message.rs new file mode 100644 index 00000000000..e1976a3016e --- /dev/null +++ b/frontend/wrapper/src/intercept_frontend_message.rs @@ -0,0 +1,20 @@ +use crate::data_transfer; +use crate::helpers::render_image_data_to_canvases; +use editor::messages::prelude::FrontendMessage; + +pub(crate) fn intercept_frontend_message(message: FrontendMessage) -> Option { + match message { + FrontendMessage::UpdateImageData { image_data, canvas_ids_in_use } => { + let images = image_data.iter().map(|image| (image.id, image.width, image.height, image.pixels.as_slice())); + render_image_data_to_canvases(images, &canvas_ids_in_use); + } + FrontendMessage::TriggerClipboardRead => { + data_transfer::read_clipboard(); + } + FrontendMessage::TriggerClipboardWrite { content } => { + data_transfer::write_clipboard(content); + } + m => return Some(m), + } + None +} diff --git a/frontend/wrapper/src/lib.rs b/frontend/wrapper/src/lib.rs index 8a92014b55e..223de2d3d8d 100644 --- a/frontend/wrapper/src/lib.rs +++ b/frontend/wrapper/src/lib.rs @@ -4,9 +4,13 @@ #[macro_use] extern crate log; +#[cfg(not(feature = "native"))] +mod data_transfer; pub mod editor_commands; pub mod editor_wrapper; pub mod helpers; +#[cfg(not(feature = "native"))] +mod intercept_frontend_message; pub mod native_communication; mod wasm_value;