notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git
feat: paste images (Ctrl+V), as Obsidian does
A picture on the clipboard (a screenshot, an image copied in a browser)
is saved as "Pasted image <timestamp>.<ext>", and image files copied in a
file manager under their own names, in Obsidian's attachment folder for
the note; a ![[...]] embed for each goes on its own line at the caret.
Copied files are looked at first, since a file manager also offers their
paths as text; a picture only when no plain text is offered, so text
copied along with an image rendering still pastes as text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 14 ++++++
Cargo.toml | 2 +
src/main.rs | 57 +++++++++++++++++++++
src/paste.rs | 162 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 235 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index 5d99aa6..cccdc4b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,6 +26,7 @@ proposal".
| `complete.rs` | `[[` completion: the open link at the caret, the shortest link text, the splice |
| `mcp.rs` | MCP tools (`search`, `find_notes`, `read_note`, `backlinks`, `open_note`, `append_daily`, `current_note`) |
| `images.rs` | Embedded images: link text → vault path → decode thread → upload; the lookup the reading view and the editor share |
+| `paste.rs` | Ctrl+V of a picture or copied image files: what the clipboard offers, storing into the attachment folder, where the embeds go |
| `instance.rs` | Single instance on `/tmp/cce-notes-<WAYLAND_DISPLAY>.sock`; the CLI's commands |
## Behaviour worth knowing before changing it
@@ -97,6 +98,19 @@ sentence and note embeds (`![[Note]]`) still show as links.
second and later renderer (`seen_renderer`). Verified with
`CCE_UI_FAULT_RECONNECT` at scale 2 against a control built without it:
the control's images went blank, these stayed.
+- **Pasting (Ctrl+V, `paste.rs`)**: a picture on the clipboard is saved
+ as `Pasted image <YYYYMMDDHHMMSS>.<ext>`, and copied image files under
+ their own names, in Obsidian's attachment folder for the note
+ (`cce_vault::attachments`); each `![[…]]` goes on its own line at the
+ caret. Copied files are checked first (a file manager offers their paths
+ as `text/plain` too); a picture is taken only when no `text/plain` is
+ offered, so text copied with an image rendering (browsers, office apps)
+ still pastes as text. The link is the bare name unless another file of
+ that name would win it. Shadow-test with `cce-shadow run wl-copy --type
+ image/png < x.png` — the shadow has its own clipboard.
+- **A line that is only an embed is its own block** in the parsed document
+ (`cce_vault::markdown`), even inside a paragraph, so reading view and
+ live preview agree on what draws as a picture.
- Rasters over 2048 px are scaled down on decode (reported at their own
size, so sizing is unchanged); SVGs show at their intrinsic size,
rasterised at twice it. Formats: png, jpeg, gif (first frame), webp,
diff --git a/Cargo.toml b/Cargo.toml
index 2a7efa4..cb200e2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,6 +17,8 @@ env_logger = "0.11"
# same resvg cce-ui draws icons with (its version, so one copy builds).
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
resvg = "0.41.0"
+# Decoding pasted file:// URIs.
+percent-encoding = "2"
[dev-dependencies]
tempfile = "3"
diff --git a/src/main.rs b/src/main.rs
index 0a4568c..c37fe59 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -26,6 +26,7 @@ mod tree;
mod complete;
mod mcp;
mod panel;
+mod paste;
mod side;
use std::path::{Path, PathBuf};
@@ -932,6 +933,56 @@ impl NotesApp {
(p.line, line[..p.col.min(line.len())].chars().count())
}
+ /// Ctrl+V with a picture (or copied image files) on the clipboard: save
+ /// them in the vault's attachment folder for this note, as Obsidian
+ /// does, and embed them at the caret, each on its own line. False when
+ /// the clipboard holds nothing of the kind (the text paste runs).
+ fn paste_images(&mut self) -> bool {
+ let (Some(ix), Some(cur)) = (self.index.as_ref(), self.current.clone()) else { return false };
+ let Some(clip) = paste::read() else {
+ log::debug!("paste: nothing to attach, pasting text");
+ return false;
+ };
+ let root = ix.root().to_path_buf();
+ let folder = cce_vault::attachments::folder(&root, &cur);
+ let written = match paste::store(clip, &root.join(&folder), chrono::Local::now().naive_local()) {
+ Ok(w) => w,
+ Err(e) => {
+ self.set_status(format!("Could not save the pasted image: {e}"), true);
+ return true;
+ }
+ };
+ // The bare name, as Obsidian writes it — unless another file of that
+ // name would win the link, then the vault path. (The index learns of
+ // the new file from the watcher, after this.)
+ let embeds: Vec<String> = written
+ .iter()
+ .filter_map(|p| {
+ let rel = ix.rel(p)?;
+ let name = p.file_name()?.to_string_lossy().into_owned();
+ let link = if ix.resolve_text(Some(&cur), &name).is_some_and(|other| other != rel) { rel } else { name };
+ Some(format!("![[{link}]]"))
+ })
+ .collect();
+ if embeds.is_empty() {
+ return true;
+ }
+ let (a, b) = self.editor.buf.selection().unwrap_or((self.editor.buf.caret, self.editor.buf.caret));
+ let before = self.editor.buf.line(a.line)[..a.col].to_string();
+ let after = self.editor.buf.line(b.line)[b.col..].to_string();
+ let (text, at) = paste::insertion(&embeds, &before, &after);
+ let head = &text[..at];
+ let caret = match head.rfind('\n') {
+ Some(nl) => Pos::new(a.line + head.matches('\n').count(), head.len() - nl - 1),
+ None => Pos::new(a.line, a.col + head.len()),
+ };
+ self.editor.edit(a, b, &text, caret);
+ let n = written.len();
+ let place = if folder.is_empty() { "the vault".to_string() } else { folder };
+ self.set_status(format!("Pasted {n} image{} into {place}", if n == 1 { "" } else { "s" }), false);
+ true
+ }
+
/// Open, update or close the completion for the caret's position.
fn update_completion(&mut self) {
if !self.editor_focused() || self.editor.buf.selection().is_some() {
@@ -2142,6 +2193,12 @@ impl Application for NotesApp {
if self.completion_key(&event.logical_key) {
return None;
}
+ // An image on the clipboard pastes as an attachment;
+ // anything else falls through to the editor's text paste.
+ if cce_ui::widget::match_key_shortcut(event, "ctrl+v") && self.paste_images() {
+ self.update_completion();
+ return None;
+ }
self.editor.key(event);
self.update_completion();
}
diff --git a/src/paste.rs b/src/paste.rs
new file mode 100644
index 0000000..9a466ab
--- /dev/null
+++ b/src/paste.rs
@@ -0,0 +1,162 @@
+//! Pasting images (Ctrl+V in the editor), as Obsidian does: a picture on
+//! the clipboard — a screenshot, an image copied in a browser — or image
+//! files copied in a file manager become files in the vault's attachment
+//! folder (`cce_vault::attachments`, Obsidian's own setting) and `![[…]]`
+//! embeds at the caret. Anything else pastes as text, through the editor.
+//!
+//! The clipboard is read with `wl-paste`, as cce-ui's text paste reads it.
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+/// Image types, best first, and the extension each is saved with.
+const IMAGE_TYPES: [(&str, &str); 5] =
+ [("image/png", "png"), ("image/jpeg", "jpg"), ("image/webp", "webp"), ("image/gif", "gif"), ("image/bmp", "bmp")];
+
+#[derive(Debug, PartialEq)]
+pub enum Clip {
+ /// Picture bytes, and the extension they are saved with.
+ Image { bytes: Vec<u8>, ext: &'static str },
+ /// Image files copied in a file manager.
+ Files(Vec<PathBuf>),
+}
+
+/// What on the clipboard a paste should turn into attachments, if anything.
+pub fn read() -> Option<Clip> {
+ let types = wl_paste(&["--list-types"])?;
+ let types: Vec<&str> = std::str::from_utf8(&types).ok()?.lines().map(str::trim).collect();
+ log::debug!("paste: clipboard offers {types:?}");
+ // Copied files first: a file manager offers their paths as text/plain
+ // too. A list naming no local image (a browser's copied link) falls
+ // through to the text paste.
+ if types.contains(&"text/uri-list") {
+ let list = wl_paste(&["--type", "text/uri-list"])?;
+ let files = image_files(&String::from_utf8_lossy(&list));
+ if !files.is_empty() {
+ return Some(Clip::Files(files));
+ }
+ }
+ let (mime, ext) = image_offer(&types)?;
+ let bytes = wl_paste(&["--type", mime])?;
+ (!bytes.is_empty()).then_some(Clip::Image { bytes, ext })
+}
+
+/// The picture type to take, if any. Only when no plain text is offered
+/// too: copying text from a browser or an office app often carries an
+/// image rendering of it as well, and that paste must stay text. A copied
+/// image (browser, screenshot tool) offers no text/plain.
+fn image_offer(types: &[&str]) -> Option<(&'static str, &'static str)> {
+ if types.iter().any(|t| t.starts_with("text/plain")) {
+ return None;
+ }
+ IMAGE_TYPES.iter().copied().find(|(m, _)| types.contains(m))
+}
+
+/// The local image files a `text/uri-list` names (comments and other files
+/// skipped).
+fn image_files(list: &str) -> Vec<PathBuf> {
+ list.lines()
+ .map(str::trim)
+ .filter(|l| !l.starts_with('#'))
+ .filter_map(|l| l.strip_prefix("file://"))
+ // `file://host/path`: only the local form, with an empty host.
+ .filter(|p| p.starts_with('/'))
+ .map(|p| PathBuf::from(percent_encoding::percent_decode_str(p).decode_utf8_lossy().into_owned()))
+ .filter(|p| cce_vault::markdown::is_image(&p.to_string_lossy()) && p.is_file())
+ .collect()
+}
+
+fn wl_paste(args: &[&str]) -> Option<Vec<u8>> {
+ let out = Command::new("wl-paste").args(args).output().ok()?;
+ out.status.success().then_some(out.stdout)
+}
+
+/// Write `clip` into `dir` (made if missing): a picture as `Pasted image
+/// <timestamp>.<ext>`, Obsidian's name; files under their own names. Names
+/// that clash are numbered. The paths written.
+pub fn store(clip: Clip, dir: &Path, now: chrono::NaiveDateTime) -> std::io::Result<Vec<PathBuf>> {
+ std::fs::create_dir_all(dir)?;
+ match clip {
+ Clip::Image { bytes, ext } => {
+ let name = format!("Pasted image {}.{ext}", now.format("%Y%m%d%H%M%S"));
+ let path = cce_vault::attachments::unique_path(dir, &name);
+ std::fs::write(&path, bytes)?;
+ Ok(vec![path])
+ }
+ Clip::Files(files) => {
+ let mut out = Vec::new();
+ for f in files {
+ let Some(name) = f.file_name() else { continue };
+ let path = cce_vault::attachments::unique_path(dir, &name.to_string_lossy());
+ std::fs::copy(&f, &path)?;
+ out.push(path);
+ }
+ Ok(out)
+ }
+ }
+}
+
+/// The text a paste of `embeds` (`![[…]]` lines) inserts between `before`
+/// and `after` (the caret line's text on either side of the selection):
+/// each embed on its own line, since only a line that is one embed shows
+/// as the picture. Returns (text, its length up to where the caret goes).
+pub fn insertion(embeds: &[String], before: &str, after: &str) -> (String, usize) {
+ let mut text = String::new();
+ if !before.trim().is_empty() {
+ text.push('\n');
+ }
+ text.push_str(&embeds.join("\n"));
+ let caret = text.len();
+ if !after.trim().is_empty() {
+ text.push('\n');
+ }
+ (text, caret)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn text_wins_over_an_image_rendering() {
+ assert_eq!(image_offer(&["image/png", "text/html"]), Some(("image/png", "png")));
+ assert_eq!(image_offer(&["image/jpeg", "image/png"]), Some(("image/png", "png")));
+ assert_eq!(image_offer(&["text/html", "text/plain;charset=utf-8", "image/png"]), None);
+ assert_eq!(image_offer(&["text/uri-list"]), None);
+ assert_eq!(image_offer(&[]), None);
+ }
+
+ #[test]
+ fn uri_lists_keep_local_images() {
+ let dir = tempfile::tempdir().unwrap();
+ let a = dir.path().join("my pic.png");
+ std::fs::write(&a, b"x").unwrap();
+ std::fs::write(dir.path().join("doc.pdf"), b"x").unwrap();
+ let list = format!(
+ "# copied\nfile://{}\nfile://{}/doc.pdf\nfile://{}/gone.png\nhttps://x.y/z.png\n",
+ a.display().to_string().replace(' ', "%20"),
+ dir.path().display(),
+ dir.path().display()
+ );
+ assert_eq!(image_files(&list), vec![a]);
+ }
+
+ #[test]
+ fn stores_with_obsidian_names() {
+ let dir = tempfile::tempdir().unwrap();
+ let now = chrono::NaiveDate::from_ymd_opt(2026, 10, 1).unwrap().and_hms_opt(21, 35, 12).unwrap();
+ let p = store(Clip::Image { bytes: b"png".to_vec(), ext: "png" }, &dir.path().join("att"), now).unwrap();
+ assert_eq!(p, vec![dir.path().join("att/Pasted image 20261001213512.png")]);
+ let p = store(Clip::Image { bytes: b"png".to_vec(), ext: "png" }, &dir.path().join("att"), now).unwrap();
+ assert_eq!(p, vec![dir.path().join("att/Pasted image 20261001213512 1.png")]);
+ }
+
+ #[test]
+ fn embeds_go_on_their_own_lines() {
+ let e = vec!["![[a.png]]".to_string()];
+ assert_eq!(insertion(&e, "", ""), ("![[a.png]]".into(), 10));
+ assert_eq!(insertion(&e, "text ", " more"), ("\n![[a.png]]\n".into(), 11));
+ let two = vec!["![[a.png]]".to_string(), "![[b.png]]".to_string()];
+ assert_eq!(insertion(&two, "", "").0, "![[a.png]]\n![[b.png]]");
+ }
+}