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

commitd61b0d3674ea95fb8543fcc90b697bb64b482d8f
parenta2a1670df6
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 08:09
feat: drop images into a note

A picture dragged from a browser (image/* pixels) or image files dragged
from a file manager (text/uri-list) are stored in the attachment folder
like a paste and embedded after the line under the pointer -- never
mid-line. A drop on reading view switches to editing and adds them at the
end of the note. Paste and drop share one attach step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md    | 10 +++++++++-
 src/main.rs  | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++-------
 src/paste.rs | 31 +++++++++++++++++++++++++++++-
 3 files changed, 93 insertions(+), 9 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index cccdc4b..dd22f75 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,7 +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 |
+| `paste.rs` | Ctrl+V and drops of a picture or image files: what the clipboard / drop 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
@@ -108,6 +108,14 @@ sentence and note embeds (`![[Note]]`) still show as links.
   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.
+- **Dropping** (`drop_mimes` / `handle_drop`) takes the same two kinds,
+  pixels first (`image/png`… — a browser's dragged picture), then
+  `text/uri-list` (a file manager). The embed goes after the line under
+  the pointer, never mid-line; a drop on reading view switches to editing
+  and adds it at the end. Shadow-test with a GTK4 drag source and
+  `ccectl pointer-press` / `pointer-move-to` / `pointer-release`; offer
+  pixels with `Gdk.ContentProvider.new_for_bytes("image/png", …)` — a
+  texture `new_for_value` from Python advertises only GTK's private type.
 - **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.
diff --git a/src/main.rs b/src/main.rs
index c37fe59..ed192dd 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -938,18 +938,30 @@ impl NotesApp {
     /// 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 };
+        if self.index.is_none() || self.current.is_none() {
+            return false;
+        }
         let Some(clip) = paste::read() else {
             log::debug!("paste: nothing to attach, pasting text");
             return false;
         };
+        let (a, b) = self.editor.buf.selection().unwrap_or((self.editor.buf.caret, self.editor.buf.caret));
+        self.attach(clip, a, b, "Pasted");
+        true
+    }
+
+    /// Store `clip` in the attachment folder for the open note and put an
+    /// embed for each file in place of `a..b`, each on its own line.
+    /// `verb` names the action in the status line.
+    fn attach(&mut self, clip: paste::Clip, a: Pos, b: Pos, verb: &str) {
+        let (Some(ix), Some(cur)) = (self.index.as_ref(), self.current.clone()) else { return };
         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;
+                self.set_status(format!("Could not save the image: {e}"), true);
+                return;
             }
         };
         // The bare name, as Obsidian writes it — unless another file of that
@@ -965,9 +977,8 @@ impl NotesApp {
             })
             .collect();
         if embeds.is_empty() {
-            return true;
+            return;
         }
-        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);
@@ -979,8 +990,33 @@ impl NotesApp {
         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
+        self.set_status(format!("{verb} {n} image{} into {place}", if n == 1 { "" } else { "s" }), false);
+    }
+
+    /// A drop on the window: images (pixels from a browser, or image files
+    /// from a file manager) become attachments embedded after the line they
+    /// were dropped on. In reading view the note switches to editing and they go at
+    /// its end.
+    fn dropped(&mut self, mime: &str, data: &[u8], x: f32, y: f32) {
+        if self.index.is_none() || self.current.is_none() {
+            self.set_status("Open a note to drop images into it", true);
+            return;
+        }
+        let Some(clip) = paste::from_drop(mime, data) else {
+            self.set_status("Only images can be dropped into a note", true);
+            return;
+        };
+        let at = if self.mode == Mode::Source && contains(self.editor_rect(&self.metrics()), x, y) {
+            // After the line under the pointer: dropping onto a word must
+            // not split it around the picture.
+            let line = self.editor.pos_at(x, y).line;
+            Pos::new(line, self.editor.buf.line(line).len())
+        } else {
+            self.set_mode(Mode::Source);
+            let last = self.editor.buf.line_count() - 1;
+            Pos::new(last, self.editor.buf.line(last).len())
+        };
+        self.attach(clip, at, at, "Dropped");
     }
 
     /// Open, update or close the completion for the caret's position.
@@ -1737,6 +1773,17 @@ impl Application for NotesApp {
         true
     }
 
+    /// Pixels first (a browser's dragged picture); a file manager's drag
+    /// offers only the file list.
+    fn drop_mimes(&self) -> &'static [&'static str] {
+        paste::DROP_MIMES
+    }
+
+    fn handle_drop(&mut self, mime: &str, data: &[u8], pos: LogicalPosition, needs_rebuild: &mut bool) {
+        self.dropped(mime, data, pos.x as f32, pos.y as f32);
+        *needs_rebuild = true;
+    }
+
     /// A reconnect builds a new renderer and every uploaded image id dies
     /// with the old one: forget them, and the next frame uploads afresh.
     /// The first renderer needs nothing (uploads queued before it drain
diff --git a/src/paste.rs b/src/paste.rs
index 9a466ab..a6306ed 100644
--- a/src/paste.rs
+++ b/src/paste.rs
@@ -1,4 +1,4 @@
-//! Pasting images (Ctrl+V in the editor), as Obsidian does: a picture on
+//! Pasting and dropping images, 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 `![[…]]`
@@ -21,6 +21,22 @@ pub enum Clip {
     Files(Vec<PathBuf>),
 }
 
+/// What a drop takes, best first: a browser's dragged picture as pixels,
+/// else the file list a file manager's drag carries.
+pub const DROP_MIMES: &[&str] = &["image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp", "text/uri-list"];
+
+/// What a drop of `data` as `mime` attaches, if anything.
+pub fn from_drop(mime: &str, data: &[u8]) -> Option<Clip> {
+    if let Some((_, ext)) = IMAGE_TYPES.iter().find(|(m, _)| *m == mime) {
+        return (!data.is_empty()).then(|| Clip::Image { bytes: data.to_vec(), ext });
+    }
+    if mime == "text/uri-list" {
+        let files = image_files(&String::from_utf8_lossy(data));
+        return (!files.is_empty()).then_some(Clip::Files(files));
+    }
+    None
+}
+
 /// What on the clipboard a paste should turn into attachments, if anything.
 pub fn read() -> Option<Clip> {
     let types = wl_paste(&["--list-types"])?;
@@ -141,6 +157,19 @@ mod tests {
         assert_eq!(image_files(&list), vec![a]);
     }
 
+    #[test]
+    fn drops_take_pixels_or_image_files() {
+        assert_eq!(from_drop("image/jpeg", b"jpg"), Some(Clip::Image { bytes: b"jpg".to_vec(), ext: "jpg" }));
+        assert_eq!(from_drop("image/png", b""), None);
+        assert_eq!(from_drop("text/uri-list", b"https://x.y/a.png\r\n"), None);
+        let dir = tempfile::tempdir().unwrap();
+        let a = dir.path().join("a.webp");
+        std::fs::write(&a, b"x").unwrap();
+        let list = format!("file://{}\r\n", a.display());
+        assert_eq!(from_drop("text/uri-list", list.as_bytes()), Some(Clip::Files(vec![a])));
+        assert_eq!(from_drop("text/html", b"<img>"), None);
+    }
+
     #[test]
     fn stores_with_obsidian_names() {
         let dir = tempfile::tempdir().unwrap();