From ada2c3d89ddd650d72d02ae9c7c581aa8c7906f9 Mon Sep 17 00:00:00 2001 From: luxick Date: Thu, 10 Sep 2026 11:47:16 +0200 Subject: [PATCH] Allow pasing text directly --- README.md | 11 +++++ src/app.rs | 129 ++++++++++++++++++++++++++++++++++++++++++--------- src/model.rs | 97 ++++++++++++++++++++++++++++++++++++++ src/view.rs | 61 +++++++++++++++++++++++- 4 files changed, 275 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e542dcb..b226a61 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ reshuffle. | Drag a group | Moves the group and everything inside it | | Drag on empty canvas | Rubber-band selection (hold Shift to add) | | Middle- or right-drag | Pan | +| Drop files on the window | A file node each; a `.canvas` file opens instead | | Wheel over a node | Scroll contents that are taller than the node | | Ctrl+wheel | Zoom around the pointer | @@ -57,6 +58,16 @@ stored as a `label` attribute — the name groups and edges already use — so a tool that does not know about it carries it through untouched. This is an addition to the format; see [`spec/extensions.md`](spec/extensions.md). +**Dropping and pasting things in**: drop files on the window and each becomes a +file node, with its path stored relative to the canvas when it lives beside it. +Paste text and it becomes a note, or a link node when the text is a URL. A copy +made in the editor goes onto the system clipboard as JSON Canvas, so it pastes +back into another window as nodes rather than as text. + +Dropping *text* on the window does nothing, and no drop lands under the pointer: +the windowing layer this editor is built on accepts file drags only, and discards +the position that comes with them. Paste is the way in for text. + **The usual editor comforts**: undo/redo of every change, copy/paste/duplicate, grouping, alignment, z-order, a properties inspector, snap-to-grid, light and dark themes, and a warning before you lose unsaved work. diff --git a/src/app.rs b/src/app.rs index bb816b9..86a247e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -285,6 +285,22 @@ impl App { ); } + /// Adds the node a piece of loose text should become: a link node when the + /// text is a URL, a note otherwise. + fn insert_node_for_text(&mut self, text: &str, at: Pos2, ctx: &Context) { + let kind = NodeKind::from_text(text); + let link = matches!(kind, NodeKind::Link { .. }); + self.view.insert_node(&mut self.doc, kind, at); + self.set_status( + if link { + "Added a link node" + } else { + "Added a text node" + }, + ctx, + ); + } + fn insert_group(&mut self, at: Pos2, ctx: &Context) { self.view.insert_node( &mut self.doc, @@ -345,6 +361,9 @@ impl App { match self.view.copy_selection(&self.doc.canvas) { Some(clipboard) => { let count = clipboard.nodes.len(); + // Also on the system clipboard, so the copy can be pasted into + // another window; `paste` recognises it again by its JSON. + ctx.copy_text(clipboard.to_json()); self.clipboard = clipboard; self.set_status(format!("Copied {count} node(s)"), ctx); } @@ -352,14 +371,36 @@ impl App { } } - fn paste(&mut self, ctx: &Context) { + /// Pastes `text` from the system clipboard, falling back to the last copy + /// made in this window when there is none (the Edit menu has no way to read + /// the system clipboard). + /// + /// Text that is a canvas pastes as nodes; any other text becomes a node of + /// its own — a link node for a URL, a note for anything else. + fn paste(&mut self, text: Option<&str>, ctx: &Context) { + if let Some(text) = text { + if let Some(clipboard) = Clipboard::from_json(text) { + self.paste_clipboard(&clipboard, ctx); + return; + } + if !text.trim().is_empty() { + let at = self.view.centre_in_canvas(); + self.insert_node_for_text(text, at, ctx); + return; + } + } if self.clipboard.is_empty() { + self.set_status("Nothing to paste", ctx); return; } let clipboard = self.clipboard.clone(); + self.paste_clipboard(&clipboard, ctx); + } + + fn paste_clipboard(&mut self, clipboard: &Clipboard, ctx: &Context) { let message = self .view - .paste(&mut self.doc, &clipboard, egui::vec2(24.0, 24.0)); + .paste(&mut self.doc, clipboard, egui::vec2(24.0, 24.0)); self.set_status(message, ctx); } @@ -411,12 +452,6 @@ impl App { if consume(ctx, CTRL, K::A) { self.view.select_all(&self.doc.canvas); } - if consume(ctx, CTRL, K::C) { - self.copy_selection(ctx); - } - if consume(ctx, CTRL, K::V) { - self.paste(ctx); - } if consume(ctx, CTRL, K::D) { self.duplicate_selection(ctx); } @@ -446,6 +481,21 @@ impl App { if ctx.egui_wants_keyboard_input() { return; } + + // Ctrl+C and Ctrl+V never arrive as key presses: the windowing layer + // turns them into clipboard events before egui sees the key, and a + // focused text field (excluded just above) needs those events itself. + // Taking them out of the queue keeps anything drawn later from acting + // on the same paste a second time. + if take_event(ctx, |e| matches!(e, egui::Event::Copy).then_some(())).is_some() { + self.copy_selection(ctx); + } + if let Some(text) = take_event(ctx, |e| match e { + egui::Event::Paste(text) => Some(text.clone()), + _ => None, + }) { + self.paste(Some(&text), ctx); + } if ctx.input_mut(|i| i.key_pressed(Key::Delete) || i.key_pressed(Key::Backspace)) { self.delete_selection(ctx); } @@ -492,6 +542,10 @@ impl App { /// Files dropped on the window: canvases are opened, anything else becomes /// a file node. + /// + /// The new nodes land in the middle of the view because the drop carries no + /// position: X11 sends the pointer along with the drag, but winit drops + /// those coordinates and reports the file alone. fn handle_dropped_files(&mut self, ctx: &Context) { let dropped: Vec = ctx.input(|i| { i.raw @@ -500,25 +554,47 @@ impl App { .map(|f| f.path().to_path_buf()) .collect() }); + if dropped.is_empty() { + return; + } + + let centre = self.view.centre_in_canvas(); + let mut added: Vec = Vec::new(); for path in dropped { if path.extension().is_some_and(|e| e == "canvas") { if self.guard(Pending::OpenPath(path.clone())) { self.open_path(&path); } - } else { - let file = inspector::relative_to(self.doc.base_dir().as_deref(), &path); - let at = self.view.centre_in_canvas(); - self.view.insert_node( - &mut self.doc, - NodeKind::File { - file: file.clone(), - subpath: None, - }, - at, - ); - self.set_status(format!("Added {file}"), ctx); + continue; } + // A file below the canvas is stored relative to it, so the pair + // survives being moved or shared; anything else keeps its full path. + let file = inspector::relative_to(self.doc.base_dir().as_deref(), &path); + // Step each further file down and right instead of stacking them all + // on the same spot. + let at = centre + egui::vec2(24.0, 24.0) * added.len() as f32; + self.view.insert_node( + &mut self.doc, + NodeKind::File { + file: file.clone(), + subpath: None, + }, + at, + ); + added.push(file); } + + let mut message = match added.as_slice() { + [] => return, + [file] => format!("Added {file}"), + files => format!("Added {} file nodes", files.len()), + }; + // An unsaved canvas has no directory for the paths to be relative to, + // so they went in absolute and will not travel with the file. + if self.doc.path().is_none() { + message.push_str(" — save the canvas to store the paths relative to it"); + } + self.set_status(message, ctx); } // -- chrome ----------------------------------------------------------- @@ -587,7 +663,7 @@ impl App { self.copy_selection(ctx); } if item(ui, "Paste", "Ctrl+V") { - self.paste(ctx); + self.paste(None, ctx); } if item(ui, "Duplicate", "Ctrl+D") { self.duplicate_selection(ctx); @@ -939,7 +1015,8 @@ const SHORTCUTS: &[(&str, &str)] = &[ ("Ctrl+N / Ctrl+O", "New / open a canvas"), ("Ctrl+S / Ctrl+Shift+S", "Save / save as"), ("Ctrl+Z / Ctrl+Shift+Z", "Undo / redo"), - ("Ctrl+C / Ctrl+V / Ctrl+D", "Copy / paste / duplicate"), + ("Ctrl+C / Ctrl+D", "Copy / duplicate the selection"), + ("Ctrl+V", "Paste nodes, or clipboard text as a node"), ("Ctrl+A", "Select everything"), ("Ctrl+G", "Put the selection in a group"), ("Ctrl+I", "Show or hide the inspector"), @@ -958,6 +1035,14 @@ fn consume(ctx: &Context, modifiers: Modifiers, key: Key) -> bool { ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(modifiers, key))) } +/// Removes the first input event `wanted` accepts and returns what it made of it. +fn take_event(ctx: &Context, wanted: impl Fn(&egui::Event) -> Option) -> Option { + ctx.input_mut(|i| { + let index = i.events.iter().position(|e| wanted(e).is_some())?; + wanted(&i.events.remove(index)) + }) +} + /// A menu entry with its shortcut, closing the menu when clicked. fn item(ui: &mut Ui, label: &str, shortcut: &str) -> bool { let clicked = ui.add(Button::new(label).shortcut_text(shortcut)).clicked(); diff --git a/src/model.rs b/src/model.rs index 844123b..b82ebaa 100644 --- a/src/model.rs +++ b/src/model.rs @@ -192,6 +192,45 @@ impl NodeKind { Self::Unknown { type_name } => type_name, } } + + /// The node a piece of loose text should become: a link when the text is a + /// URL and nothing else, a note otherwise. + pub fn from_text(text: &str) -> Self { + match url_in(text) { + Some(url) => Self::Link { url, label: None }, + None => Self::Text { + text: text.to_owned(), + }, + } + } +} + +/// Reads `text` as a URL, returning it in a form a browser will accept. +/// +/// Only text that says it is a URL counts: an explicit scheme, or the `www.` +/// prefix browsers themselves treat as one. A bare `example.com` is left alone +/// because the same shape is far more often a file name — `notes.md` and +/// `archive.zip` would otherwise turn into links. +pub fn url_in(text: &str) -> Option { + let text = text.trim(); + // A URL is one word. Anything with a line break or a space in it is prose. + if text.is_empty() || text.chars().any(|c| c.is_whitespace() || c.is_control()) { + return None; + } + if let Some(rest) = text.strip_prefix("www.") { + // `www.` on its own is not a host. + return (!rest.is_empty()).then(|| format!("https://{text}")); + } + let scheme_end = text.find(':')?; + let scheme = &text[..scheme_end]; + let named = !scheme.is_empty() + && scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')); + // A scheme needs something after it, and `C:/tmp` is a path, not a URL. + let has_target = text.len() > scheme_end + 1; + (named && scheme.len() > 1 && has_target).then(|| text.to_owned()) } /// A node of the canvas. @@ -797,4 +836,62 @@ mod tests { let ids: std::collections::HashSet = (0..1000).map(|_| random_id()).collect(); assert_eq!(ids.len(), 1000); } + + #[test] + fn text_that_is_a_url_becomes_a_link() { + for raw in [ + "https://jsoncanvas.org", + "http://example.com/a?b=c#d", + " https://example.com \n", + "mailto:someone@example.com", + "ftp://files.example.com/pub", + "www.example.com", + ] { + match NodeKind::from_text(raw) { + NodeKind::Link { url, label } => { + let scheme = ["http", "mailto:", "ftp:"]; + assert!(scheme.iter().any(|s| url.starts_with(s)), "{url}"); + assert_eq!(label, None); + } + other => panic!("{raw:?} became {}", other.type_name()), + } + } + // A bare `www.` host is spelled out for the browser. + assert_eq!( + url_in("www.example.com").as_deref(), + Some("https://www.example.com") + ); + } + + #[test] + fn text_that_is_not_a_url_becomes_a_note() { + for raw in [ + "Just a thought", + "https://example.com and some words", + "Read https://example.com\nlater", + "example.com", + "notes.md", + "C:/Users/me/notes.md", + "", + " ", + "a:", + ":8080", + ] { + let kind = NodeKind::from_text(raw); + assert!( + matches!(kind, NodeKind::Text { .. }), + "{raw:?} became {}", + kind.type_name() + ); + } + } + + #[test] + fn a_note_keeps_the_text_exactly_as_it_was_pasted() { + let raw = " # Heading\n\n body "; + match NodeKind::from_text(raw) { + NodeKind::Text { text } => assert_eq!(text, raw), + other => panic!("became {}", other.type_name()), + } + } } diff --git a/src/view.rs b/src/view.rs index 41c5cf0..fb4d1aa 100644 --- a/src/view.rs +++ b/src/view.rs @@ -17,7 +17,7 @@ use crate::geometry::{ anchor, auto_sides, bezier_point, bezier_tangent, bounds_of, curve_between, distance_to_curve, edge_curve, node_rect, }; -use crate::model::{Canvas, Color, Edge, EndShape, Node, NodeKind, Side, MIN_NODE_SIZE}; +use crate::model::{Canvas, Color, Edge, EndShape, Extra, Node, NodeKind, Side, MIN_NODE_SIZE}; use crate::nodes::{self, BodyContext, PreviewCache}; use crate::theme::{self, Palette}; @@ -1596,6 +1596,29 @@ impl Clipboard { pub fn is_empty(&self) -> bool { self.nodes.is_empty() } + + /// The copy as a JSON Canvas document, so that it can travel through the + /// system clipboard and into another window or another editor. + pub fn to_json(&self) -> String { + Canvas { + nodes: self.nodes.clone(), + edges: self.edges.clone(), + extra: Extra::new(), + } + .to_json() + } + + /// Reads back what [`Clipboard::to_json`] wrote. + /// + /// `None` for anything else, including a canvas without nodes: that text is + /// pasted as a node of its own instead. + pub fn from_json(text: &str) -> Option { + let canvas = Canvas::from_json(text).ok()?; + (!canvas.nodes.is_empty()).then_some(Self { + nodes: canvas.nodes, + edges: canvas.edges, + }) + } } /// Nodes are drawn in two passes so that edges end up between the groups and @@ -1944,6 +1967,42 @@ mod tests { use super::*; use crate::model::Canvas; + #[test] + fn a_copy_survives_the_system_clipboard() { + let mut canvas = Canvas::default(); + let note = |id: &str, x| Node::new(id, NodeKind::Text { text: id.into() }, x, 0, 250, 60); + canvas.nodes.push(note("a", 0)); + canvas.nodes.push(note("b", 400)); + canvas + .edges + .push(Edge::new("e", "a", Side::Right, "b", Side::Left)); + + let mut view = CanvasView::new(); + view.select_all(&canvas); + let copied = view.copy_selection(&canvas).expect("a selection to copy"); + + let back = Clipboard::from_json(&copied.to_json()).expect("the copy to be recognised"); + assert_eq!(back.nodes, copied.nodes); + assert_eq!(back.edges, copied.edges); + } + + #[test] + fn plain_text_is_not_mistaken_for_a_copy() { + for text in [ + "https://jsoncanvas.org", + "Just a thought", + "{}", + "{\"nodes\": []}", + "[1, 2, 3]", + "", + ] { + assert!( + Clipboard::from_json(text).is_none(), + "{text:?} was read as a copy" + ); + } + } + /// Runs one egui pass over the canvas with the given input events. fn pass( ctx: &egui::Context,