[geary: 1/10] First changes for supporting drag & dropped and pasted images
- From: Michael Gratton <mjog src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [geary: 1/10] First changes for supporting drag & dropped and pasted images
- Date: Sun, 17 Nov 2019 05:52:04 +0000 (UTC)
commit b4e3f196ddcdf3edf8b0934f75fb69550b956c3b
Author: Chris Heywood <chris theheywoods id au>
Date: Wed Sep 18 12:24:01 2019 +0200
First changes for supporting drag & dropped and pasted images
See #90 and #304. Work in progress.
src/client/composer/composer-web-view.vala | 43 +++++++-
src/client/composer/composer-widget.vala | 125 +++++++++++++++++++---
src/client/web-process/web-process-extension.vala | 3 +-
src/engine/api/geary-composed-email.vala | 28 ++---
src/engine/rfc822/rfc822-message.vala | 68 ++++++++++--
ui/composer-web-view.js | 24 +++++
6 files changed, 250 insertions(+), 41 deletions(-)
---
diff --git a/src/client/composer/composer-web-view.vala b/src/client/composer/composer-web-view.vala
index 0f230f7c..0b03b389 100644
--- a/src/client/composer/composer-web-view.vala
+++ b/src/client/composer/composer-web-view.vala
@@ -14,7 +14,7 @@ public class ComposerWebView : ClientWebView {
// WebKit message handler names
private const string CURSOR_CONTEXT_CHANGED = "cursorContextChanged";
-
+ private const string DRAG_DROP_RECEIVED = "dragDropReceived";
/**
* Encapsulates editing-related state for a specific DOM node.
@@ -108,6 +108,9 @@ public class ComposerWebView : ClientWebView {
/** Emitted when the cursor's edit context has changed. */
public signal void cursor_context_changed(EditContext cursor_context);
+ /** Emitted when an image file has been dropped on the composer */
+ public signal void image_file_dropped(string filename, string type, uint8[] contents);
+
/** Workaround for WebView eating the button event */
internal signal bool button_release_event_done(Gdk.Event event);
@@ -121,6 +124,7 @@ public class ComposerWebView : ClientWebView {
this.user_content_manager.add_script(ComposerWebView.app_script);
register_message_handler(CURSOR_CONTEXT_CHANGED, on_cursor_context_changed);
+ register_message_handler(DRAG_DROP_RECEIVED, on_drag_drop_received);
// XXX this is a bit of a hack given the docs for is_empty,
// above
@@ -514,4 +518,41 @@ public class ComposerWebView : ClientWebView {
}
}
+ /**
+ * Handle a dropped image
+ */
+ private void on_drag_drop_received(WebKit.JavascriptResult result) {
+ string native_result;
+ try {
+ native_result = Util.JS.to_string(result.get_js_value());
+ } catch (Util.JS.Error err) {
+ warning("Failed to decode drag & drop data: %s", err.message);
+ return;
+ }
+
+ string[] pieces = native_result.split(",");
+
+ if (pieces.length != 4) {
+ warning("Invalid data received in drag & drop: %s", native_result);
+ return;
+ }
+
+ string filename = pieces[0];
+ string filename_unescaped = GLib.Uri.unescape_string(filename);
+ string file_type = pieces[1];
+ string content_base64 = pieces[3];
+ uint8[] image = GLib.Base64.decode(content_base64);
+
+ if (image.length == 0) {
+ warning("%s is empty", filename);
+ return;
+ }
+
+ // A simple check to see if the file looks like an image. A problem here
+ // will be this accepting types which won't be supported by WebKit
+ // or recipients.
+ if (file_type.index_of("image/") == 0) {
+ image_file_dropped(filename_unescaped, file_type, image);
+ }
+ }
}
diff --git a/src/client/composer/composer-widget.vala b/src/client/composer/composer-widget.vala
index 3cbc21b6..bc867b54 100644
--- a/src/client/composer/composer-widget.vala
+++ b/src/client/composer/composer-widget.vala
@@ -167,6 +167,8 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
// allowed.
private const string ATTACHMENT_KEYWORDS_LOCALIZED =
_("attach|attaching|attaches|attachment|attachments|attached|enclose|enclosed|enclosing|encloses|enclosure|enclosures");
+ private const string PASTED_IMAGE_FILENAME_TEMPLATE = "geary-pasted-image-%u.png";
+
public Geary.Account account { get; private set; }
private Gee.Map<string, Geary.AccountInformation> accounts;
@@ -351,8 +353,8 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
private AttachPending pending_include = AttachPending.INLINE_ONLY;
private Gee.Set<File> attached_files = new Gee.HashSet<File>(Geary.Files.nullable_hash,
Geary.Files.nullable_equal);
- private Gee.Map<string,File> inline_files = new Gee.HashMap<string,File>();
- private Gee.Map<string,File> cid_files = new Gee.HashMap<string,File>();
+ private Gee.Map<string,Geary.Memory.Buffer> inline_files = new Gee.HashMap<string,Geary.Memory.Buffer>();
+ private Gee.Map<string,Geary.Memory.Buffer> cid_files = new Gee.HashMap<string,Geary.Memory.Buffer>();
private Geary.App.DraftManager? draft_manager = null;
private GLib.Cancellable? draft_manager_opening = null;
@@ -504,6 +506,12 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
this.application.engine.account_unavailable.connect(
on_account_unavailable
);
+
+ // Listen for drag and dropped image file
+ this.editor.image_file_dropped.connect(
+ on_image_file_dropped
+ );
+
// TODO: also listen for account updates to allow adding identities while writing an email
this.from = new Geary.RFC822.MailboxAddresses.single(account.information.primary_mailbox);
@@ -1605,9 +1613,10 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
// using a cid: URL anyway, so treat it as an
// attachment instead.
if (content_id != null) {
- this.cid_files[content_id] = file;
+ Geary.Memory.FileBuffer file_buffer = new Geary.Memory.FileBuffer(file, true);
+ this.cid_files[content_id] = file_buffer;
this.editor.add_internal_resource(
- content_id, new Geary.Memory.FileBuffer(file, true)
+ content_id, file_buffer
);
} else {
type = Geary.Mime.DispositionType.ATTACHMENT;
@@ -1623,7 +1632,10 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
!this.attached_files.contains(file) &&
!this.inline_files.has_key(content_id)) {
if (type == Geary.Mime.DispositionType.INLINE) {
- add_inline_part(file, content_id);
+ check_attachment_file(file);
+ Geary.Memory.FileBuffer file_buffer = new Geary.Memory.FileBuffer(file,
true);
+ string unused;
+ add_inline_part(file_buffer, content_id, out unused);
} else {
add_attachment_part(file);
}
@@ -1672,18 +1684,41 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
update_attachments_view();
}
- private void add_inline_part(File target, string content_id)
+ private void add_inline_part(Geary.Memory.Buffer target, string content_id, out string unique_contentid)
throws AttachmentError {
- check_attachment_file(target);
- this.inline_files[content_id] = target;
- try {
- this.editor.add_internal_resource(
- content_id, new Geary.Memory.FileBuffer(target, true)
+
+ const string UNIQUE_RENAME_TEMPLATE = "%s_%02u";
+
+ if (target.size == 0)
+ throw new AttachmentError.FILE(
+ _("“%s” is an empty file.").printf(content_id)
);
- } catch (Error err) {
- // unlikely
- debug("Failed to re-open file for attachment: %s", err.message);
+
+ // Avoid filename conflicts
+ unique_contentid = content_id;
+ int suffix_index = 0;
+ string unsuffixed_filename = "";
+ while (this.inline_files.has_key(unique_contentid)) {
+ string[] filename_parts = unique_contentid.split(".");
+
+ // Handle no file extension
+ int partindex;
+ if (filename_parts.length > 1) {
+ partindex = filename_parts.length-2;
+ } else {
+ partindex = 0;
+ }
+ if (unsuffixed_filename == "")
+ unsuffixed_filename = filename_parts[partindex];
+ filename_parts[partindex] = UNIQUE_RENAME_TEMPLATE.printf(unsuffixed_filename, suffix_index++);
+
+ unique_contentid = string.joinv(".", filename_parts);
}
+
+ this.inline_files[unique_contentid] = target;
+ this.editor.add_internal_resource(
+ unique_contentid, target
+ );
}
private FileInfo check_attachment_file(File target)
@@ -1855,7 +1890,14 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
private void on_paste(SimpleAction action, Variant? param) {
if (this.container.get_focus() == this.editor) {
if (this.editor.is_rich_text) {
- this.editor.paste_rich_text();
+ // Check for pasted image in clipboard
+ Gtk.Clipboard clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD);
+ bool has_image = clipboard.wait_is_image_available();
+ if (has_image) {
+ paste_image();
+ } else {
+ this.editor.paste_rich_text();
+ }
} else {
this.editor.paste_plain_text();
}
@@ -1864,6 +1906,35 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
}
}
+ /**
+ * Handle a pasted image, adding it as an inline attachment
+ */
+ private void paste_image() {
+ get_clipboard(Gdk.SELECTION_CLIPBOARD).request_image((clipboard, pixbuf) => {
+ if (pixbuf != null) {
+ try {
+ uint8[] buffer;
+ pixbuf.save_to_buffer(out buffer, "png");
+ Geary.Memory.ByteBuffer byte_buffer = new Geary.Memory.ByteBuffer(buffer, buffer.length);
+
+ // TODO Review placeholder filename generation
+ GLib.DateTime time_now = new GLib.DateTime.now();
+ string filename = PASTED_IMAGE_FILENAME_TEMPLATE.printf(time_now.hash());
+
+ string unique_filename;
+ add_inline_part(byte_buffer, filename, out unique_filename);
+ this.editor.insert_image(
+ ClientWebView.INTERNAL_URL_PREFIX + unique_filename
+ );
+ } catch (Error error) {
+ warning("Failed to paste image %s", error.message);
+ }
+ } else {
+ warning("Failed to get image from clipboard");
+ }
+ });
+ }
+
private void on_paste_without_formatting(SimpleAction action, Variant? param) {
if (this.container.get_focus() == this.editor)
this.editor.paste_plain_text();
@@ -2476,10 +2547,13 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
dialog.hide();
foreach (File file in dialog.get_files()) {
try {
+ check_attachment_file(file);
+ Geary.Memory.FileBuffer file_buffer = new Geary.Memory.FileBuffer(file, true);
string path = file.get_path();
- add_inline_part(file, path);
+ string unique_filename;
+ add_inline_part(file_buffer, path, out unique_filename);
this.editor.insert_image(
- ClientWebView.INTERNAL_URL_PREFIX + path
+ ClientWebView.INTERNAL_URL_PREFIX + unique_filename
);
} catch (Error err) {
attachment_failed(err.message);
@@ -2535,4 +2609,21 @@ public class ComposerWidget : Gtk.EventBox, Geary.BaseInterface {
}
}
+ /**
+ * Handle a dropped image file, adding it as an inline attachment
+ */
+ private void on_image_file_dropped(string filename, string file_type, uint8[] contents) {
+ Geary.Memory.ByteBuffer buffer = new Geary.Memory.ByteBuffer(contents, contents.length);
+ string unique_filename;
+ try {
+ add_inline_part(buffer, filename, out unique_filename);
+ } catch (AttachmentError err) {
+ warning("Couldn't attach dropped empty file %s", filename);
+ return;
+ }
+
+ this.editor.insert_image(
+ ClientWebView.INTERNAL_URL_PREFIX + unique_filename
+ );
+ }
}
diff --git a/src/client/web-process/web-process-extension.vala
b/src/client/web-process/web-process-extension.vala
index 1644aca2..2074189d 100644
--- a/src/client/web-process/web-process-extension.vala
+++ b/src/client/web-process/web-process-extension.vala
@@ -28,8 +28,7 @@ public void webkit_web_extension_initialize_with_user_data(WebKit.WebExtension e
*/
public class GearyWebExtension : Object {
-
- private const string[] ALLOWED_SCHEMES = { "cid", "geary", "data" };
+ private const string[] ALLOWED_SCHEMES = { "cid", "geary", "data", "blob" };
private WebKit.WebExtension extension;
diff --git a/src/engine/api/geary-composed-email.vala b/src/engine/api/geary-composed-email.vala
index 5b9be5f2..14953e78 100644
--- a/src/engine/api/geary-composed-email.vala
+++ b/src/engine/api/geary-composed-email.vala
@@ -40,10 +40,10 @@ public class Geary.ComposedEmail : BaseObject {
public Gee.Set<File> attached_files { get; private set;
default = new Gee.HashSet<File>(Geary.Files.nullable_hash, Geary.Files.nullable_equal); }
- public Gee.Map<string,File> inline_files { get; private set;
- default = new Gee.HashMap<string,File>(); }
- public Gee.Map<string,File> cid_files { get; private set;
- default = new Gee.HashMap<string,File>(); }
+ public Gee.Map<string,Memory.Buffer> inline_files { get; private set;
+ default = new Gee.HashMap<string,Memory.Buffer>(); }
+ public Gee.Map<string,Memory.Buffer> cid_files { get; private set;
+ default = new Gee.HashMap<string,Memory.Buffer>(); }
public string img_src_prefix { get; set; default = ""; }
@@ -90,18 +90,18 @@ public class Geary.ComposedEmail : BaseObject {
public bool replace_inline_img_src(string orig, string replacement) {
// XXX This and contains_inline_img_src are pretty
// hacky. Should probably be working with a DOM tree.
- bool ret = false;
+ bool found = false;
if (this.body_html != null) {
- string old_body = this.body_html;
- this.body_html = old_body.replace(
- IMG_SRC_TEMPLATE.printf(this.img_src_prefix + orig),
- IMG_SRC_TEMPLATE.printf(replacement)
- );
- // Avoid doing a proper comparison so we don't need to scan
- // the whole string again.
- ret = this.body_html.length != old_body.length;
+ string prefixed_orig = IMG_SRC_TEMPLATE.printf(this.img_src_prefix + orig);
+ found = this.body_html.contains(prefixed_orig);
+ if (found) {
+ this.body_html = this.body_html.replace(
+ prefixed_orig,
+ IMG_SRC_TEMPLATE.printf(replacement)
+ );
+ }
}
- return ret;
+ return found;
}
}
diff --git a/src/engine/rfc822/rfc822-message.vala b/src/engine/rfc822/rfc822-message.vala
index f600c929..b41d73bb 100644
--- a/src/engine/rfc822/rfc822-message.vala
+++ b/src/engine/rfc822/rfc822-message.vala
@@ -236,7 +236,7 @@ public class Geary.RFC822.Message : BaseObject, EmailHeaderSet {
new Gee.LinkedList<GMime.Object>();
// The files that need to have Content IDs assigned
- Gee.Map<string,File> inline_files = new Gee.HashMap<string,File>();
+ Gee.Map<string,Memory.Buffer> inline_files = new Gee.HashMap<string,Memory.Buffer>();
inline_files.set_all(email.inline_files);
// Create parts for inline images, if any, and updating
@@ -248,18 +248,18 @@ public class Geary.RFC822.Message : BaseObject, EmailHeaderSet {
// assigned
foreach (string cid in email.cid_files.keys) {
if (email.contains_inline_img_src(CID_URL_PREFIX + cid)) {
- File file = email.cid_files[cid];
GMime.Object? inline_part = null;
try {
- inline_part = yield get_file_part(
- file,
+ inline_part = yield get_buffer_part(
+ email.cid_files[cid],
+ GLib.Path.get_basename(cid),
Geary.Mime.DispositionType.INLINE,
cancellable
);
} catch (GLib.Error err) {
warning(
"Error creating CID part %s: %s",
- file.get_path(),
+ cid,
err.message
);
}
@@ -288,15 +288,16 @@ public class Geary.RFC822.Message : BaseObject, EmailHeaderSet {
CID_URL_PREFIX + cid)) {
GMime.Object? inline_part = null;
try {
- inline_part = yield get_file_part(
+ inline_part = yield get_buffer_part(
inline_files[name],
+ GLib.Path.get_basename(name),
Geary.Mime.DispositionType.INLINE,
cancellable
);
} catch (GLib.Error err) {
warning(
"Error creating inline file part %s: %s",
- inline_files[name].get_path(),
+ name,
err.message
);
}
@@ -445,6 +446,59 @@ public class Geary.RFC822.Message : BaseObject, EmailHeaderSet {
GMime.StreamGIO stream = new GMime.StreamGIO(file);
stream.set_owner(false);
+ return yield finalise_attachment_part(stream, part, content_type, cancellable);
+ }
+
+ /**
+ * Create a GMime part for the provided attachment buffer
+ */
+ private async GMime.Part? get_buffer_part(Memory.Buffer buffer,
+ string basename,
+ Geary.Mime.DispositionType disposition,
+ GLib.Cancellable cancellable)
+ throws Error {
+
+ Mime.ContentType? mime_type = Mime.ContentType.guess_type(
+ basename,
+ buffer
+ );
+
+ if (mime_type == null) {
+ throw new RFC822Error.INVALID(
+ _("Could not determine mime type for “%s”.").printf(basename)
+ );
+ }
+
+ GMime.ContentType? content_type = new GMime.ContentType.from_string(mime_type.get_mime_type());
+
+ if (content_type == null) {
+ throw new RFC822Error.INVALID(
+ _("Could not determine content type for mime type “%s” on
“%s”.").printf(mime_type.to_string(), basename)
+ );
+ }
+
+ GMime.Part part = new GMime.Part();
+ part.set_disposition(disposition.serialize());
+ part.set_filename(basename);
+
+ part.set_content_type(content_type);
+
+ // TODO seems inefficient, surely there's a way to create a Glib.Stream using, say Memory.Buffer's
InputStream.
+ GMime.StreamMem stream = new GMime.StreamMem.with_buffer(buffer.get_uint8_array());
+ stream.set_owner(false);
+
+ return yield finalise_attachment_part(stream, part, content_type, cancellable);
+ }
+
+ /**
+ * Set encoding and content object on GMime part
+ */
+ private async GMime.Part finalise_attachment_part(GMime.Stream stream,
+ GMime.Part part,
+ GMime.ContentType content_type,
+ GLib.Cancellable cancellable)
+ throws Error {
+
// Text parts should be scanned fully to determine best
// (i.e. most compact) transport encoding to use, but
// that's usually fine since they tend to be
diff --git a/ui/composer-web-view.js b/ui/composer-web-view.js
index 524850ca..188db982 100644
--- a/ui/composer-web-view.js
+++ b/ui/composer-web-view.js
@@ -92,6 +92,12 @@ ComposerPageState.prototype = {
}
}, true);
+ // Handle file drag & drop
+ document.body.addEventListener("drop", state.handleFileDrop, true);
+ document.body.addEventListener("allowDrop", function(e) {
+ ev.preventDefault();
+ }, true);
+
// Search for and remove a particular styling when we quote
// text. If that style exists in the quoted text, we alter it
// slightly so we don't mess with it later.
@@ -396,6 +402,24 @@ ComposerPageState.prototype = {
}
}
return inPart;
+ },
+ handleFileDrop: function(dropEvent) {
+ dropEvent.preventDefault();
+
+ for (var i = 0; i < dropEvent.dataTransfer.files.length; i++) {
+ const file = dropEvent.dataTransfer.files[i];
+
+ if (!file.type.startsWith('image/'))
+ continue;
+
+ const reader = new FileReader();
+ reader.onload = (function(filename, imageType) { return function(loadEvent) {
+ window.webkit.messageHandlers.dragDropReceived.postMessage(
+ encodeURIComponent(filename) + "," + imageType + "," + loadEvent.target.result
+ );
+ }; })(file.name, file.type);
+ reader.readAsDataURL(file);
+ }
}
};
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]