git.lucas.co / cce-notes
notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git

commit81c62b3b29a4c6108a8278004cfff6e93f4cf93f
parentf9d9618aa5
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 21:51
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]]");
+    }
+}