Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Copy-pasting nodes #7618

Merged
merged 18 commits into from
Aug 30, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/gui/controller/double-representation/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ impl NodeAst {

/// AST of the node's expression. Typically no external user wants to access it directly. Use
/// [`Self::expression`] instead.
Comment on lines 459 to 460
Copy link
Contributor

Choose a reason for hiding this comment

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

Docs accurate?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, it's still not typical to use it, but in this case we need it.

fn whole_expression(&self) -> &Ast {
pub fn whole_expression(&self) -> &Ast {
match self {
NodeAst::Binding { infix, .. } => &infix.rarg,
NodeAst::Expression { ast, .. } => ast,
Expand Down
2 changes: 2 additions & 0 deletions app/gui/docs/product/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ broken and require further investigation.
| <kbd>cmd</kbd>+<kbd>alt</kbd>+<kbd>r</kbd> | Re-execute the program |
| <kbd>cmd</kbd>+<kbd>shift</kbd>+<kbd>k</kbd> | Switch the execution environment to Design. |
| <kbd>cmd</kbd>+<kbd>shift</kbd>+<kbd>l</kbd> | Switch the execution environment to Live. |
| <kbd>cmd</kbd>+<kbd>c</kbd> | Copy the selected nodes to the clipboard. |
| <kbd>cmd</kbd>+<kbd>v</kbd> | Paste a node from the clipboard at the mouse cursor position. |

#### Navigation

Expand Down
21 changes: 21 additions & 0 deletions app/gui/src/controller/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ pub use double_representation::graph::LocationHint;



mod clipboard;



// ==============
// === Errors ===
// ==============
Expand Down Expand Up @@ -929,6 +933,23 @@ impl Handle {
Ok(())
}

/// Copy the node to clipboard. See `clipboard` module documentation for details.
pub fn copy_node(&self, id: ast::Id) -> FallibleResult {
let graph = GraphInfo::from_definition(self.definition()?.item);
let node = graph.locate_node(id)?;
let expression = node.whole_expression().repr();
let metadata = self.module.node_metadata(id).ok();
clipboard::copy_node(expression, metadata)?;
Ok(())
}

/// Paste a node from clipboard at cursor position. See `clipboard` module documentation for
/// details.
pub fn paste_node(&self, cursor_pos: Vector2) -> FallibleResult {
clipboard::paste_node(self, cursor_pos)?;
Ok(())
}

/// Sets the given's node expression.
#[profile(Debug)]
pub fn set_expression(&self, id: ast::Id, expression_text: impl Str) -> FallibleResult {
Expand Down
139 changes: 139 additions & 0 deletions app/gui/src/controller/graph/clipboard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//! Copy-pasting nodes using the clipboard.
//!
//! # Clipboard Content Format
//!
//! We use a JSON-encoded [`ClipboardContent`] structure, marked with our custom [`MIME_TYPE`].
//! This way, we have a separate clipboard format for our application and can extend it in the
//! future.
//! We also support plain text pasting to make it easier to paste the content from other
//! applications, but only if the [`PLAIN_TEXT_PASTING_ENABLED`] is `true`. Allowing pasting plain
//! text can bring unnecessary security risks, like the execution of malicious code immediately
//! after pasting.
//!
//! To copy the node as plain text, the user can enter the editing node, select the node expression,
//! and copy it to the clipboard using the [`ensogl::Text`] functionality.

use crate::prelude::*;

use crate::controller::graph::Handle;
use crate::controller::graph::NewNodeInfo;
use crate::model::module::NodeMetadata;

use ensogl::system::web::clipboard;
use serde::Deserialize;
use serde::Serialize;



/// We use the `web` prefix to be able to use a custom MIME type. Typically browsers support a
/// restricted set of MIME types in the clipboard.
/// See [Clipboard pickling](https://github.com/w3c/editing/blob/gh-pages/docs/clipboard-pickling/explainer.md).
///
/// `application/enso` is not an officially registered MIME-type (yet), but it is not important for
/// our purposes.
const MIME_TYPE: &str = "web application/enso";
/// Whether to allow pasting nodes from plain text.
const PLAIN_TEXT_PASTING_ENABLED: bool = true;

/// Clipboard payload.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
enum ClipboardContent {
/// A single node that was copied from the application.
Node(CopiedNode),
}

/// A single node that was copied from the application.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct CopiedNode {
/// A whole node's expression (without a pattern).
expression: String,
/// Node's metadata.
metadata: Option<NodeMetadata>,
}

/// Copy the node to the clipboard.
pub fn copy_node(expression: String, metadata: Option<NodeMetadata>) -> FallibleResult {
let content = ClipboardContent::Node(CopiedNode { expression, metadata });
let text_repr = serde_json::to_string(&content)?;
clipboard::write(text_repr.as_bytes(), MIME_TYPE.to_string());
Ok(())
}


/// Paste the node from the clipboard at a specific position.
pub fn paste_node(graph: &Handle, position: Vector2) -> FallibleResult {
clipboard::read(
MIME_TYPE.to_string(),
paste_node_from_custom_format(graph, position),
plain_text_fallback(graph, position),
);
Ok(())
}

/// A standard callback for pasting node using our custom format.
fn paste_node_from_custom_format(graph: &Handle, position: Vector2) -> impl Fn(Vec<u8>) + 'static {
let graph = graph.clone_ref();
move |content| {
let _transaction = graph.module.get_or_open_transaction("Paste node");
let string = String::from_utf8(content).unwrap();
if let Ok(content) = serde_json::from_str(&string) {
match content {
ClipboardContent::Node(node) => {
let expression = node.expression;
let metadata = node.metadata;
if let Err(err) = graph.new_node_at_position(position, expression, metadata) {
error!("Failed to paste node. {err}");
}
}
}
} else {
error!(
"`application/enso` MIME-type is used, but clipboard content has incorrect format."
);
}
}
}

/// An alternative callback for pasting node from plain text. It is used when [`MIME_TYPE`] is not
/// available in the clipboard, and only if [`PLAIN_TEXT_PASTING_ENABLED`]. Otherwise, it is a
/// noop.
fn plain_text_fallback(graph: &Handle, position: Vector2) -> impl Fn(String) + 'static {
let graph = graph.clone_ref();
move |text| {
if PLAIN_TEXT_PASTING_ENABLED {
let _transaction = graph.module.get_or_open_transaction("Paste node");
let expression = text;
if let Err(err) = graph.new_node_at_position(position, expression, None) {
error!("Failed to paste node. {err}");
}
}
}
}



// ===============
// === Helpers ===
// ===============

impl Handle {
/// Create a new node at the provided position.
fn new_node_at_position(
&self,
position: Vector2,
expression: String,
metadata: Option<NodeMetadata>,
) -> FallibleResult {
let info = NewNodeInfo {
expression,
doc_comment: None,
metadata,
id: None,
location_hint: double_representation::graph::LocationHint::End,
introduce_pattern: true,
};
let ast_id = self.add_node(info)?;
self.set_node_position(ast_id, position)?;
Ok(())
}
}
20 changes: 19 additions & 1 deletion app/gui/src/presenter/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,16 @@ impl Model {
Some((node_id, config))
}

fn node_copied(&self, id: ViewNodeId) {
self.log_action(
|| {
let ast_id = self.state.ast_node_id_of_view(id)?;
Some(self.controller.graph().copy_node(ast_id))
},
"copy node",
)
}

/// Node was removed in view.
fn node_removed(&self, id: ViewNodeId) {
self.log_action(
Expand Down Expand Up @@ -433,6 +443,12 @@ impl Model {
}
}

fn paste_node(&self, cursor_pos: Vector2) {
if let Err(err) = self.controller.graph().paste_node(cursor_pos) {
error!("Error when pasting node: {err}");
}
}

/// Look through all graph's nodes in AST and set position where it is missing.
#[profile(Debug)]
fn initialize_nodes_positions(&self, default_gap_between_nodes: f32) {
Expand Down Expand Up @@ -751,6 +767,7 @@ impl Graph {

// === Changes from the View ===

eval view.node_copied((node_id) model.node_copied(*node_id));
eval view.node_position_set_batched(((node_id, position)) model.node_position_changed(*node_id, *position));
eval view.node_removed((node_id) model.node_removed(*node_id));
eval view.nodes_collapsed(((nodes, _)) model.nodes_collapsed(nodes));
Expand All @@ -765,8 +782,9 @@ impl Graph {
eval_ view.reopen_file_in_language_server (model.reopen_file_in_ls());


// === Dropping Files ===
// === Dropping Files and Pasting Node ===

eval view.request_paste_node((pos) model.paste_node(*pos));
file_upload_requested <- view.file_dropped.gate(&project_view.drop_files_enabled);
eval file_upload_requested (((file,position)) model.file_dropped(file.clone_ref(),*position));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,6 @@
//! [`SpanWidget::PRIORITY_OVER_OVERRIDE`] as `true` in their implementation. In that case, the
//! override will be applied again on their children that use the same span-tree node.



use crate::prelude::*;

use crate::component::node::input::area::NODE_HEIGHT;
Expand Down
19 changes: 18 additions & 1 deletion app/gui/view/graph-editor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,9 @@ ensogl::define_endpoints_2! {
/// opposed to e.g. when loading a graph from a file).
start_node_creation_from_port(),


// === Copy-Paste ===
copy_selected_node(),
paste_node(),


/// Remove all selected nodes from the graph.
Expand Down Expand Up @@ -718,6 +720,13 @@ ensogl::define_endpoints_2! {
node_being_edited (Option<NodeId>),
node_editing (bool),


// === Copy-Paste ===

node_copied(NodeId),
// Paste node at position.
request_paste_node(Vector2),

file_dropped (ensogl_drop_manager::File,Vector2<f32>),

connection_made (Connection),
Expand Down Expand Up @@ -3005,6 +3014,14 @@ fn init_remaining_graph_editor_frp(
}


// === Copy-Paste ===

frp::extend! { network
out.node_copied <+ inputs.copy_selected_node.map(f_!(model.nodes.last_selected())).unwrap();
out.request_paste_node <+ cursor.position.sample(&inputs.paste_node).map(|v| v.xy());
}


// === Set Node Comment ===
frp::extend! { network

Expand Down
3 changes: 3 additions & 0 deletions app/gui/view/graph-editor/src/shortcuts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ pub const SHORTCUTS: &[(ensogl::application::shortcut::ActionType, &str, &str, &
(Release, "!read_only", "cmd", "edit_mode_off"),
(Press, "!read_only", "cmd left-mouse-button", "edit_mode_on"),
(Release, "!read_only", "cmd left-mouse-button", "edit_mode_off"),
// === Copy-paste ===
(Press, "!node_editing", "cmd c", "copy_selected_node"),
(Press, "!read_only", "cmd v", "paste_node"),
// === Debug ===
(Press, "debug_mode", "ctrl d", "debug_set_test_visualization_data_for_selected_node"),
(Press, "debug_mode", "ctrl n", "add_node_at_cursor"),
Expand Down
63 changes: 59 additions & 4 deletions lib/rust/web/js/clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,35 +72,90 @@ export function writeText(text) {
}
}

/// Write custom `data` payload to the clipboard. Data will be saved as a `Blob` with `mimeType`.
///
/// Unlike `writeText`, there are no special fallbacks in case of errors or the clipboard being unavailable.
/// If writing did not succeeed, the function will simply log an error to the console.
export function writeCustom(mimeType, data) {
if (!navigator.clipboard) {
console.error('Clipboard API not available.')
} else {
const blob = new Blob([data], { type: mimeType })
navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]).then(
() => {},
err => {
console.error('Could not write to clipboard.', err)
}
)
}
}

/// Firefox only supports reading the clipboard in browser extensions, so it will
/// only work with `cmd + v` shortcut. To learn more, see the
/// [MSDN compatibility note](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/readText).
let lastPaste = ''
let lastTextPaste = ''
function init_firefox_fallback() {
// Checking whether the window is defined. It could not be defined if the program is run in
// node, for example to extract the shaders.
if (typeof window !== 'undefined') {
window.addEventListener('paste', event => {
lastPaste = (event.clipboardData || window.clipboardData).getData('text')
lastTextPaste = (event.clipboardData || window.clipboardData).getData('text')
})
}
}

export function readText(callback) {
if (!navigator.clipboard) {
callback(lastPaste)
callback(lastTextPaste)
} else {
navigator.clipboard.readText().then(
function (text) {
callback(text)
},
function (err) {
callback(lastPaste)
callback(lastTextPaste)
}
)
}
}

/// Read a custom payload of `expectedMimeType` from the clipboard, passing it to `whenExpecte` callback.
vitvakatu marked this conversation as resolved.
Show resolved Hide resolved
/// If there is no value of `expectedMimeType` in the payload, use `plainTextFallback` callback instead.
///
/// Unlike `readText`, there are no special fallbacks in case of errors or the clipboard being unavailable.
/// If reading did not succeeed, the function will simply log an error to the console.
export function readCustom(expectedMimeType, whenExpected, plainTextFallback) {
if (!navigator.clipboard) {
console.error('Clipboard API not available.')
} else {
readCustomImpl(expectedMimeType, whenExpected, plainTextFallback)
}
}

/// Helper function for `readCustom`, see its documentation.
async function readCustomImpl(expectedMimeType, whenExpected, plainTextFallback) {
try {
const data = await navigator.clipboard.read()
if (data.length === 0) {
return
}
const item = data[0]
if (item.types.includes(expectedMimeType)) {
const blob = await item.getType(expectedMimeType)
const buffer = await blob.arrayBuffer()
whenExpected(new Uint8Array(buffer))
} else if (item.types.includes('text/plain')) {
const blob = await item.getType('text/plain')
const text = await blob.text()
plainTextFallback(text)
} else {
console.error('Unexpected clipboard payload MIME-type.', item.types)
}
} catch (error) {
console.error('Error while reading clipboard.', error)
}
}

// ======================
// === Initialization ===
// ======================
Expand Down
Loading