Allow pasing text directly

This commit is contained in:
2026-09-10 11:47:16 +02:00
parent 2d7e8aa20f
commit ada2c3d89d
4 changed files with 275 additions and 23 deletions
+11
View File
@@ -38,6 +38,7 @@ reshuffle.
| Drag a group | Moves the group and everything inside it | | Drag a group | Moves the group and everything inside it |
| Drag on empty canvas | Rubber-band selection (hold <kbd>Shift</kbd> to add) | | Drag on empty canvas | Rubber-band selection (hold <kbd>Shift</kbd> to add) |
| Middle- or right-drag | Pan | | 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 | | Wheel over a node | Scroll contents that are taller than the node |
| <kbd>Ctrl</kbd>+wheel | Zoom around the pointer | | <kbd>Ctrl</kbd>+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 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). 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, **The usual editor comforts**: undo/redo of every change, copy/paste/duplicate,
grouping, alignment, z-order, a properties inspector, snap-to-grid, light and grouping, alignment, z-order, a properties inspector, snap-to-grid, light and
dark themes, and a warning before you lose unsaved work. dark themes, and a warning before you lose unsaved work.
+98 -13
View File
@@ -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) { fn insert_group(&mut self, at: Pos2, ctx: &Context) {
self.view.insert_node( self.view.insert_node(
&mut self.doc, &mut self.doc,
@@ -345,6 +361,9 @@ impl App {
match self.view.copy_selection(&self.doc.canvas) { match self.view.copy_selection(&self.doc.canvas) {
Some(clipboard) => { Some(clipboard) => {
let count = clipboard.nodes.len(); 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.clipboard = clipboard;
self.set_status(format!("Copied {count} node(s)"), ctx); 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() { if self.clipboard.is_empty() {
self.set_status("Nothing to paste", ctx);
return; return;
} }
let clipboard = self.clipboard.clone(); let clipboard = self.clipboard.clone();
self.paste_clipboard(&clipboard, ctx);
}
fn paste_clipboard(&mut self, clipboard: &Clipboard, ctx: &Context) {
let message = self let message = self
.view .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); self.set_status(message, ctx);
} }
@@ -411,12 +452,6 @@ impl App {
if consume(ctx, CTRL, K::A) { if consume(ctx, CTRL, K::A) {
self.view.select_all(&self.doc.canvas); 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) { if consume(ctx, CTRL, K::D) {
self.duplicate_selection(ctx); self.duplicate_selection(ctx);
} }
@@ -446,6 +481,21 @@ impl App {
if ctx.egui_wants_keyboard_input() { if ctx.egui_wants_keyboard_input() {
return; 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)) { if ctx.input_mut(|i| i.key_pressed(Key::Delete) || i.key_pressed(Key::Backspace)) {
self.delete_selection(ctx); self.delete_selection(ctx);
} }
@@ -492,6 +542,10 @@ impl App {
/// Files dropped on the window: canvases are opened, anything else becomes /// Files dropped on the window: canvases are opened, anything else becomes
/// a file node. /// 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) { fn handle_dropped_files(&mut self, ctx: &Context) {
let dropped: Vec<PathBuf> = ctx.input(|i| { let dropped: Vec<PathBuf> = ctx.input(|i| {
i.raw i.raw
@@ -500,14 +554,25 @@ impl App {
.map(|f| f.path().to_path_buf()) .map(|f| f.path().to_path_buf())
.collect() .collect()
}); });
if dropped.is_empty() {
return;
}
let centre = self.view.centre_in_canvas();
let mut added: Vec<String> = Vec::new();
for path in dropped { for path in dropped {
if path.extension().is_some_and(|e| e == "canvas") { if path.extension().is_some_and(|e| e == "canvas") {
if self.guard(Pending::OpenPath(path.clone())) { if self.guard(Pending::OpenPath(path.clone())) {
self.open_path(&path); self.open_path(&path);
} }
} else { 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); let file = inspector::relative_to(self.doc.base_dir().as_deref(), &path);
let at = self.view.centre_in_canvas(); // 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( self.view.insert_node(
&mut self.doc, &mut self.doc,
NodeKind::File { NodeKind::File {
@@ -516,9 +581,20 @@ impl App {
}, },
at, at,
); );
self.set_status(format!("Added {file}"), ctx); 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 ----------------------------------------------------------- // -- chrome -----------------------------------------------------------
@@ -587,7 +663,7 @@ impl App {
self.copy_selection(ctx); self.copy_selection(ctx);
} }
if item(ui, "Paste", "Ctrl+V") { if item(ui, "Paste", "Ctrl+V") {
self.paste(ctx); self.paste(None, ctx);
} }
if item(ui, "Duplicate", "Ctrl+D") { if item(ui, "Duplicate", "Ctrl+D") {
self.duplicate_selection(ctx); self.duplicate_selection(ctx);
@@ -939,7 +1015,8 @@ const SHORTCUTS: &[(&str, &str)] = &[
("Ctrl+N / Ctrl+O", "New / open a canvas"), ("Ctrl+N / Ctrl+O", "New / open a canvas"),
("Ctrl+S / Ctrl+Shift+S", "Save / save as"), ("Ctrl+S / Ctrl+Shift+S", "Save / save as"),
("Ctrl+Z / Ctrl+Shift+Z", "Undo / redo"), ("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+A", "Select everything"),
("Ctrl+G", "Put the selection in a group"), ("Ctrl+G", "Put the selection in a group"),
("Ctrl+I", "Show or hide the inspector"), ("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))) 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<T>(ctx: &Context, wanted: impl Fn(&egui::Event) -> Option<T>) -> Option<T> {
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. /// A menu entry with its shortcut, closing the menu when clicked.
fn item(ui: &mut Ui, label: &str, shortcut: &str) -> bool { fn item(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
let clicked = ui.add(Button::new(label).shortcut_text(shortcut)).clicked(); let clicked = ui.add(Button::new(label).shortcut_text(shortcut)).clicked();
+97
View File
@@ -192,6 +192,45 @@ impl NodeKind {
Self::Unknown { type_name } => type_name, 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<String> {
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. /// A node of the canvas.
@@ -797,4 +836,62 @@ mod tests {
let ids: std::collections::HashSet<String> = (0..1000).map(|_| random_id()).collect(); let ids: std::collections::HashSet<String> = (0..1000).map(|_| random_id()).collect();
assert_eq!(ids.len(), 1000); 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()),
}
}
} }
+60 -1
View File
@@ -17,7 +17,7 @@ use crate::geometry::{
anchor, auto_sides, bezier_point, bezier_tangent, bounds_of, curve_between, distance_to_curve, anchor, auto_sides, bezier_point, bezier_tangent, bounds_of, curve_between, distance_to_curve,
edge_curve, node_rect, 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::nodes::{self, BodyContext, PreviewCache};
use crate::theme::{self, Palette}; use crate::theme::{self, Palette};
@@ -1596,6 +1596,29 @@ impl Clipboard {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.nodes.is_empty() 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<Self> {
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 /// Nodes are drawn in two passes so that edges end up between the groups and
@@ -1944,6 +1967,42 @@ mod tests {
use super::*; use super::*;
use crate::model::Canvas; 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. /// Runs one egui pass over the canvas with the given input events.
fn pass( fn pass(
ctx: &egui::Context, ctx: &egui::Context,