notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git
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();