[geary/wip/728002-webkit2] Replace composer link dialog with a popover.



commit a2285b63d283d3ba3b72ef7f8e9968f1454be7e0
Author: Michael James Gratton <mike vee net>
Date:   Thu Jan 19 02:23:57 2017 +1100

    Replace composer link dialog with a popover.
    
    * src/client/composer/composer-link-popover.vala: New GtkPopover subclass
      for creating/editing links.
    
    * src/client/composer/composer-web-view.vala (EditContext): Add is_link
      and link_uri properties, decode them from the message string, add
      decoding tests.
      (ComposerWebView): Add some as-yet un-implemented methods for
      inserting/deleting links.
    
    * src/client/composer/composer-widget.vala (ComposerWidget): Add
      cursor_url for storing current text cursor link, update it from the
      cursor_context_changed signal param, rename hover_url to pointer_url to
      match. Add link_activated signal to let user's open links they are
      adding, hook that up in the controller. Rename
      ::update_selection_actions to ::update_cursor_actions, since that's a
      little more apt now, also enable insert link action if there is a
      cursor_url set as well as a selection. Remove ::link_dialog, replace
      with ::new_link_popover, hook up the new popover's signals there as
      appropriate.
      (ComposerWidget::on_insert_link): Create and show a lin popover instead
      of a dialog.
    
    * ui/composer-web-view.js: Take note of whther the context node is a link
      and if so, also it's href. Include both when serialsing for the
      cursorContextChanged message. Add serialisation tests.
    
    * ui/composer-link-popover.ui: New UI for link popover.

 po/POTFILES.in                                   |    2 +
 src/CMakeLists.txt                               |    1 +
 src/client/application/geary-controller.vala     |    1 +
 src/client/composer/composer-link-popover.vala   |  189 ++++++++++++++++++++++
 src/client/composer/composer-web-view.vala       |   29 +++-
 src/client/composer/composer-widget.vala         |  141 ++++++----------
 test/client/composer/composer-web-view-test.vala |   12 +-
 test/js/composer-page-state-test.vala            |   19 ++-
 ui/CMakeLists.txt                                |    1 +
 ui/composer-link-popover.ui                      |  130 +++++++++++++++
 ui/composer-web-view.js                          |   12 ++
 11 files changed, 441 insertions(+), 96 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 054ffb2..f5939d4 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -38,6 +38,7 @@ src/client/composer/composer-box.vala
 src/client/composer/composer-container.vala
 src/client/composer/composer-embed.vala
 src/client/composer/composer-headerbar.vala
+src/client/composer/composer-link-popover.vala
 src/client/composer/composer-web-view.vala
 src/client/composer/composer-widget.vala
 src/client/composer/composer-window.vala
@@ -386,6 +387,7 @@ src/mailer/main.vala
 [type: gettext/glade]ui/account_spinner.glade
 [type: gettext/glade]ui/certificate_warning_dialog.glade
 [type: gettext/glade]ui/composer-headerbar.ui
+[type: gettext/glade]ui/composer-link-popover.ui
 [type: gettext/glade]ui/composer-menus.ui
 [type: gettext/glade]ui/composer-widget.ui
 [type: gettext/glade]ui/conversation-email.ui
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index a91beda..bb0ad05 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -350,6 +350,7 @@ client/composer/composer-box.vala
 client/composer/composer-container.vala
 client/composer/composer-embed.vala
 client/composer/composer-headerbar.vala
+client/composer/composer-link-popover.vala
 client/composer/composer-web-view.vala
 client/composer/composer-widget.vala
 client/composer/composer-window.vala
diff --git a/src/client/application/geary-controller.vala b/src/client/application/geary-controller.vala
index 1c780a0..8452fba 100644
--- a/src/client/application/geary-controller.vala
+++ b/src/client/application/geary-controller.vala
@@ -2330,6 +2330,7 @@ public class GearyController : Geary.BaseObject {
             yield widget.restore_draft_state_async();
         }
 
+        widget.link_activated.connect((uri) => { open_uri(uri); });
         widget.show_all();
 
         // We want to keep track of the open composer windows, so we can allow the user to cancel
diff --git a/src/client/composer/composer-link-popover.vala b/src/client/composer/composer-link-popover.vala
new file mode 100644
index 0000000..d51d312
--- /dev/null
+++ b/src/client/composer/composer-link-popover.vala
@@ -0,0 +1,189 @@
+/*
+ * Copyright 2017 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+
+/**
+ * A popover for editing a link in the composer.
+ *
+ * The exact appearance of the popover will depend on the {@link
+ * Type} passed to the constructor:
+ *
+ * - For {@link Type.NEW_LINK}, the user will be presented with an
+ *   insert button and an open button.
+ * - For {@link Type.EXISTING_LINK}, the user will be presented with
+ *   an update, delete and open buttons.
+ */
+[GtkTemplate (ui = "/org/gnome/Geary/composer-link-popover.ui")]
+public class ComposerLinkPopover : Gtk.Popover {
+
+    private const string[] HTTP_SCHEMES = { "http", "https" };
+    private const string[] OTHER_SCHEMES = {
+        "aim", "apt", "bitcoin", "cvs", "ed2k", "ftp", "file", "finger",
+        "git", "gtalk", "irc", "ircs", "irc6", "lastfm", "ldap", "ldaps",
+        "magnet", "news", "nntp", "rsync", "sftp", "skype", "smb", "sms",
+        "svn", "telnet", "tftp", "ssh", "webcal", "xmpp"
+    };
+
+    /** Determines which version of the UI is presented to the user. */
+    public enum Type {
+        /** A new link is being created. */
+        NEW_LINK,
+
+        /** An existing link is being edited. */
+        EXISTING_LINK,
+    }
+
+    /** The URL displayed in the popover */
+    public string link_uri { get { return this.url.get_text(); } }
+
+    [GtkChild]
+    private Gtk.Entry url;
+
+    [GtkChild]
+    private Gtk.Button insert;
+
+    [GtkChild]
+    private Gtk.Button update;
+
+    [GtkChild]
+    private Gtk.Button delete;
+
+    [GtkChild]
+    private Gtk.Button open;
+
+    private Geary.TimeoutManager validation_timeout;
+
+
+    /** Emitted when the link URL has changed. */
+    public signal void link_changed(Soup.URI? uri, bool is_valid);
+
+    /** Emitted when the link URL was activated. */
+    public signal void link_activate();
+
+    /** Emitted when the open button was activated. */
+    public signal void link_open();
+
+    /** Emitted when the delete button was activated. */
+    public signal void link_delete();
+
+
+    public ComposerLinkPopover(Type type) {
+        set_default_widget(this.url);
+        set_focus_child(this.url);
+        switch (type) {
+        case Type.NEW_LINK:
+            this.update.hide();
+            this.delete.hide();
+            break;
+        case Type.EXISTING_LINK:
+            this.insert.hide();
+            break;
+        }
+        this.validation_timeout = new Geary.TimeoutManager.milliseconds(
+            150, () => { validate(); }
+        );
+    }
+
+    ~ComposerLinkPopover() {
+        debug("Destructing...");
+    }
+
+    public override void destroy() {
+        this.validation_timeout.reset();
+        base.destroy();
+    }
+
+    public void set_link_url(string url) {
+        this.url.set_text(url);
+        this.validation_timeout.reset(); // Don't update on manual set
+    }
+
+    private void validate() {
+        string? text = this.url.get_text().strip();
+        bool is_empty = Geary.String.is_empty(text);
+        bool is_valid = false;
+        bool is_nominal = false;
+        bool is_mailto = false;
+        Soup.URI? url = null;
+        if (!is_empty) {
+            url = new Soup.URI(text);
+            if (url != null) {
+                is_valid = true;
+
+                string? scheme = url.get_scheme();
+                string? path = url.get_path();
+                if (scheme in HTTP_SCHEMES) {
+                    is_nominal = Geary.Inet.is_valid_display_host(url.get_host());
+                } else if (scheme == "mailto") {
+                    is_mailto = true;
+                    is_nominal = (
+                        !Geary.String.is_empty(path) &&
+                        Geary.RFC822.MailboxAddress.is_valid_address(path)
+                    );
+                } else if (scheme in OTHER_SCHEMES) {
+                    is_nominal = !Geary.String.is_empty(path);
+                }
+            } else if (text == "http:/" || text == "https:/") {
+                // Don't let the URL entry switch to invalid and back
+                // between "http:" and "http://";
+                is_valid = true;
+            }
+        }
+
+        // Don't let the user open invalid and mailto links, it's not
+        // terribly useful
+        this.open.set_sensitive(is_nominal && !is_mailto);
+
+        Gtk.StyleContext style = this.url.get_style_context();
+        Gtk.EntryIconPosition pos = Gtk.EntryIconPosition.SECONDARY;
+        if (!is_valid) {
+            style.add_class(Gtk.STYLE_CLASS_ERROR);
+            style.remove_class(Gtk.STYLE_CLASS_WARNING);
+            this.url.set_icon_from_icon_name(pos, "dialog-error-symbolic");
+            this.url.set_tooltip_text(
+                _("Link URL is not correctly formatted, e.g. http://example.com";)
+            );
+        } else if (!is_nominal) {
+            style.remove_class(Gtk.STYLE_CLASS_ERROR);
+            style.add_class(Gtk.STYLE_CLASS_WARNING);
+            this.url.set_icon_from_icon_name(pos, "dialog-warning-symbolic");
+            this.url.set_tooltip_text(
+                !is_mailto ? _("Invalid link URL") : _("Invalid email address")
+            );
+        } else {
+            style.remove_class(Gtk.STYLE_CLASS_ERROR);
+            style.remove_class(Gtk.STYLE_CLASS_WARNING);
+            this.url.set_icon_from_icon_name(pos, null);
+            this.url.set_tooltip_text(null);
+        }
+
+        link_changed(url, is_valid && is_nominal);
+    }
+
+    [GtkCallback]
+    private void on_url_changed() {
+        this.validation_timeout.start();
+    }
+
+    [GtkCallback]
+    private void on_activate_popover() {
+        link_activate();
+        this.popdown();
+    }
+
+    [GtkCallback]
+    private void on_delete_clicked() {
+        link_delete();
+        this.popdown();
+    }
+
+    [GtkCallback]
+    private void on_open_clicked() {
+        link_open();
+    }
+
+}
diff --git a/src/client/composer/composer-web-view.vala b/src/client/composer/composer-web-view.vala
index 79dc233..5546b35 100644
--- a/src/client/composer/composer-web-view.vala
+++ b/src/client/composer/composer-web-view.vala
@@ -99,13 +99,20 @@ public class ComposerWebView : ClientWebView {
         }
 
 
+        public bool is_link { get { return (this.context & LINK_MASK) > 0; } }
+        public string link_url { get; private set; default = ""; }
         public string font_family { get; private set; default = "sans"; }
         public uint font_size { get; private set; default = 12; }
 
+        private uint context = 0;
+
         public EditContext(string message) {
             string[] values = message.split(",");
+            this.context = (uint) uint64.parse(values[0]);
+
+            this.link_url = values[1];
 
-            string view_name = values[0].down();
+            string view_name = values[2].down();
             foreach (string specific_name in EditContext.font_family_map.keys) {
                 if (specific_name in view_name) {
                     this.font_family = EditContext.font_family_map[specific_name];
@@ -113,7 +120,7 @@ public class ComposerWebView : ClientWebView {
                 }
             }
 
-            this.font_size = (uint) uint64.parse(values[1]);
+            this.font_size = (uint) uint64.parse(values[3]);
         }
 
     }
@@ -303,7 +310,23 @@ public class ComposerWebView : ClientWebView {
     }
 
     /**
-     * Inserts an IMG with the given `src` at the current cursor location.
+     * Inserts or updates an A element at the current text cursor location.
+     *
+     * If the cursor is located on an A element, the element's HREF
+     * will be updated, else if some text is selected, an A element
+     * will be inserted wrapping the selection.
+     */
+    public void insert_link(string href) {
+    }
+
+    /**
+     * Removes any A element at the current text cursor location.
+     */
+    public void delete_link() {
+    }
+
+    /**
+     * Inserts an IMG element at the current text cursor location.
      */
     public void insert_image(string src) {
         // Use insertHTML instead of insertImage here so
diff --git a/src/client/composer/composer-widget.vala b/src/client/composer/composer-widget.vala
index dc716f0..d9c0a42 100644
--- a/src/client/composer/composer-widget.vala
+++ b/src/client/composer/composer-widget.vala
@@ -1,7 +1,9 @@
-/* Copyright 2016 Software Freedom Conservancy Inc.
+/*
+ * Copyright 2016 Software Freedom Conservancy Inc.
+ * Copyright 2017 Michael Gratton <mike vee net>
  *
  * This software is licensed under the GNU Lesser General Public License
- * (version 2.1 or later).  See the COPYING file in this distribution.
+ * (version 2.1 or later). See the COPYING file in this distribution.
  */
 
 private errordomain AttachmentError {
@@ -310,6 +312,8 @@ public class ComposerWidget : Gtk.EventBox {
     [GtkChild]
     private Gtk.Box font_style_buttons;
     [GtkChild]
+    private Gtk.Button insert_link_button;
+    [GtkChild]
     private Gtk.Button remove_format_button;
     [GtkChild]
     private Gtk.Button select_dictionary_button;
@@ -332,7 +336,8 @@ public class ComposerWidget : Gtk.EventBox {
     private Menu context_menu_inspector;
 
     private SpellCheckPopover? spell_check_popover = null;
-    private string? hover_url = null;
+    private string? pointer_url = null;
+    private string? cursor_url = null;
     private bool is_attachment_overlay_visible = false;
     private Geary.RFC822.MailboxAddresses reply_to_addresses;
     private Geary.RFC822.MailboxAddresses reply_cc_addresses;
@@ -361,8 +366,12 @@ public class ComposerWidget : Gtk.EventBox {
     }
 
 
+    /** Fired when the current saved draft's id has changed. */
     public signal void draft_id_changed(Geary.EmailIdentifier? id);
 
+    /** Fired when the user opens a link in the composer. */
+    public signal void link_activated(string url);
+
 
     public ComposerWidget(Geary.Account account, ComposeType compose_type, Configuration config,
         Geary.Email? referred = null, string? quote = null, bool is_referred_draft = false) {
@@ -506,9 +515,7 @@ public class ComposerWidget : Gtk.EventBox {
         this.editor.key_press_event.connect(on_editor_key_press_event);
         this.editor.load_changed.connect(on_load_changed);
         this.editor.mouse_target_changed.connect(on_mouse_target_changed);
-        this.editor.selection_changed.connect((has_selection) => {
-                update_selection_actions(has_selection);
-            });
+        this.editor.selection_changed.connect((has_selection) => { update_cursor_actions(); });
 
         this.editor.load_html(this.body_html, this.signature_html, this.top_posting);
 
@@ -792,17 +799,20 @@ public class ComposerWidget : Gtk.EventBox {
         get_action(ACTION_UNDO).set_enabled(false);
         get_action(ACTION_REDO).set_enabled(false);
 
-        // No initial selection
-        update_selection_actions(false);
+        update_cursor_actions();
     }
 
-    private void update_selection_actions(bool has_selection) {
+    private void update_cursor_actions() {
+        bool has_selection = this.editor.has_selection;
         get_action(ACTION_CUT).set_enabled(has_selection);
         get_action(ACTION_COPY).set_enabled(has_selection);
 
-        bool rich_text_selected = has_selection && this.editor.is_rich_text;
-        get_action(ACTION_INSERT_LINK).set_enabled(rich_text_selected);
-        get_action(ACTION_REMOVE_FORMAT).set_enabled(rich_text_selected);
+        get_action(ACTION_INSERT_LINK).set_enabled(
+            this.editor.is_rich_text && (has_selection || this.cursor_url != null)
+        );
+        get_action(ACTION_REMOVE_FORMAT).set_enabled(
+            this.editor.is_rich_text && has_selection
+        );
     }
 
     private bool check_preferred_from_address(Gee.List<Geary.RFC822.MailboxAddress> account_addresses,
@@ -1724,7 +1734,9 @@ public class ComposerWidget : Gtk.EventBox {
 
     private void on_copy_link(SimpleAction action, Variant? param) {
         Gtk.Clipboard c = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD);
-        c.set_text(this.hover_url, -1);
+        // XXX could this also be the cursor URL? We should be getting
+        // the target URL as from the action param
+        c.set_text(this.pointer_url, -1);
         c.store();
     }
 
@@ -1764,7 +1776,7 @@ public class ComposerWidget : Gtk.EventBox {
         foreach (string html_action in html_actions)
             get_action(html_action).set_enabled(compose_as_html);
 
-        update_selection_actions(this.editor.has_selection);
+        update_cursor_actions();
 
         this.insert_buttons.visible = compose_as_html;
         this.font_style_buttons.visible = compose_as_html;
@@ -1825,82 +1837,12 @@ public class ComposerWidget : Gtk.EventBox {
         this.editor.undo_blockquote_style();
     }
 
-    private void link_dialog(string link) {
-        // Gtk.Dialog dialog = new Gtk.Dialog();
-        // bool existing_link = false;
-
-        // // Save information needed to re-establish selection
-        // WebKit.DOM.DOMSelection selection = this.editor.get_dom_document().get_default_view().
-        //     get_selection();
-        // WebKit.DOM.Node anchor_node = selection.anchor_node;
-        // long anchor_offset = selection.anchor_offset;
-        // WebKit.DOM.Node focus_node = selection.focus_node;
-        // long focus_offset = selection.focus_offset;
-
-        // // Allow user to remove link if they're editing an existing one.
-        // if (focus_node != null && (focus_node is WebKit.DOM.HTMLAnchorElement ||
-        //     focus_node.get_parent_element() is WebKit.DOM.HTMLAnchorElement)) {
-        //     existing_link = true;
-        //     dialog.add_buttons(Stock._REMOVE, Gtk.ResponseType.REJECT);
-        // }
-
-        // dialog.add_buttons(Stock._CANCEL, Gtk.ResponseType.CANCEL, Stock._OK,
-        //     Gtk.ResponseType.OK);
-
-        // Gtk.Entry entry = new Gtk.Entry();
-        // entry.changed.connect(() => {
-        //     // Only allow OK when there's text in the box.
-        //     dialog.set_response_sensitive(Gtk.ResponseType.OK,
-        //         !Geary.String.is_empty(entry.text.strip()));
-        // });
-
-        // dialog.width_request = 350;
-        // dialog.get_content_area().spacing = 7;
-        // dialog.get_content_area().border_width = 10;
-        // dialog.get_content_area().pack_start(new Gtk.Label("Link URL:"));
-        // dialog.get_content_area().pack_start(entry);
-        // dialog.get_widget_for_response(Gtk.ResponseType.OK).can_default = true;
-        // dialog.set_default_response(Gtk.ResponseType.OK);
-        // dialog.show_all();
-
-        // entry.set_text(link);
-        // entry.activates_default = true;
-        // entry.move_cursor(Gtk.MovementStep.BUFFER_ENDS, 0, false);
-
-        // int response = dialog.run();
-
-        // // Re-establish selection, since selecting text in the Entry will de-select all
-        // // in the WebView.
-        // try {
-        //     selection.set_base_and_extent(anchor_node, anchor_offset, focus_node, focus_offset);
-        // } catch (Error e) {
-        //     debug("Error re-establishing selection: %s", e.message);
-        // }
-
-        // if (response == Gtk.ResponseType.OK)
-        //     this.editor.execute_editing_command_with_argument("createLink", entry.text);
-        // else if (response == Gtk.ResponseType.REJECT)
-        //     this.editor.execute_editing_command("unlink");
-
-        // dialog.destroy();
-
-        // Re-bind to anchor links.  This must be done every time link have changed.
-        //Util.DOM.bind_event(this.editor,"a", "click", (Callback) on_link_clicked, this);
-    }
-
-
     private void on_mouse_target_changed(WebKit.WebView web_view,
                                          WebKit.HitTestResult hit_test,
                                          uint modifiers) {
-        bool copy_link_enabled = false;
-        if (hit_test.context_is_link()) {
-            copy_link_enabled = true;
-            this.hover_url = hit_test.get_link_uri();
-            this.message_overlay_label.label = this.hover_url;
-        } else {
-            this.hover_url = null;
-            this.message_overlay_label.label = "";
-        }
+        bool copy_link_enabled = hit_test.context_is_link();
+        this.pointer_url = copy_link_enabled ? hit_test.get_link_uri() : null;
+        this.message_overlay_label.label = this.pointer_url ?? "";
         get_action(ACTION_COPY_LINK).set_enabled(copy_link_enabled);
     }
 
@@ -2220,6 +2162,23 @@ public class ComposerWidget : Gtk.EventBox {
         this.signature_html = account_sig;
     }
 
+    private ComposerLinkPopover new_link_popover(ComposerLinkPopover.Type type,
+                                                 string url) {
+        ComposerLinkPopover popover = new ComposerLinkPopover(type);
+        popover.set_link_url(url);
+        popover.hide.connect(() => {
+                Idle.add(() => { popover.destroy(); return Source.REMOVE; });
+            });
+        popover.link_activate.connect((link_uri) => {
+                this.editor.insert_link(popover.link_uri);
+            });
+        popover.link_delete.connect(() => {
+                this.editor.delete_link();
+            });
+        popover.link_open.connect(() => { link_activated(popover.link_uri); });
+        return popover;
+    }
+
     private void on_command_state_changed(bool can_undo, bool can_redo) {
         get_action(ACTION_UNDO).set_enabled(can_undo);
         get_action(ACTION_REDO).set_enabled(can_redo);
@@ -2255,6 +2214,8 @@ public class ComposerWidget : Gtk.EventBox {
     }
 
     private void on_cursor_context_changed(ComposerWebView.EditContext context) {
+        this.cursor_url = context.is_link ? context.link_url : null;
+        update_cursor_actions();
 
         this.actions.change_action_state(ACTION_FONT_FAMILY, context.font_family);
 
@@ -2333,7 +2294,11 @@ public class ComposerWidget : Gtk.EventBox {
     }
 
     private void on_insert_link(SimpleAction action, Variant? param) {
-        link_dialog("http://";);
+        ComposerLinkPopover popover = this.cursor_url == null
+            ? new_link_popover(ComposerLinkPopover.Type.NEW_LINK, "http://";)
+            : new_link_popover(ComposerLinkPopover.Type.EXISTING_LINK, this.cursor_url);
+        popover.set_relative_to(this.insert_link_button);
+        popover.show();
     }
 
     private void on_open_inspector(SimpleAction action, Variant? param) {
diff --git a/test/client/composer/composer-web-view-test.vala 
b/test/client/composer/composer-web-view-test.vala
index 1ad2c06..5383e48 100644
--- a/test/client/composer/composer-web-view-test.vala
+++ b/test/client/composer/composer-web-view-test.vala
@@ -20,11 +20,15 @@ public class ComposerWebViewTest : ClientWebViewTestCase<ComposerWebView> {
     }
 
     public void edit_context() {
-        assert(new ComposerWebView.EditContext("Helvetica,").font_family == "sans");
-        assert(new ComposerWebView.EditContext("Times New Roman,").font_family == "serif");
-        assert(new ComposerWebView.EditContext("Courier,").font_family == "monospace");
+        assert(!(new ComposerWebView.EditContext("0,,,").is_link));
+        assert(new ComposerWebView.EditContext("1,,,").is_link);
+        assert(new ComposerWebView.EditContext("1,url,,").link_url == "url");
 
-        assert(new ComposerWebView.EditContext(",12").font_size == 12);
+        assert(new ComposerWebView.EditContext("0,,Helvetica,").font_family == "sans");
+        assert(new ComposerWebView.EditContext("0,,Times New Roman,").font_family == "serif");
+        assert(new ComposerWebView.EditContext("0,,Courier,").font_family == "monospace");
+
+        assert(new ComposerWebView.EditContext("0,,,12").font_size == 12);
     }
 
     public void get_html() {
diff --git a/test/js/composer-page-state-test.vala b/test/js/composer-page-state-test.vala
index 09eef3c..68b7279 100644
--- a/test/js/composer-page-state-test.vala
+++ b/test/js/composer-page-state-test.vala
@@ -10,6 +10,7 @@ class ComposerPageStateTest : ClientWebViewTestCase<ComposerWebView> {
     public ComposerPageStateTest() {
         base("ComposerPageStateTest");
         add_test("edit_context_font", edit_context_font);
+        add_test("edit_context_link", edit_context_link);
         add_test("get_html", get_html);
         add_test("get_text", get_text);
         add_test("get_text_with_quote", get_text_with_quote);
@@ -19,13 +20,29 @@ class ComposerPageStateTest : ClientWebViewTestCase<ComposerWebView> {
         add_test("replace_non_breaking_space", replace_non_breaking_space);
     }
 
+    public void edit_context_link() {
+        string html = "<a id=\"test\" href=\"url\">para</a>";
+        load_body_fixture(html);
+
+        try {
+            assert(run_javascript(@"new EditContext(document.getElementById('test')).encode()")
+                   .has_prefix("1,url,"));
+        } catch (Geary.JS.Error err) {
+            print("Geary.JS.Error: %s\n", err.message);
+            assert_not_reached();
+        } catch (Error err) {
+            print("WKError: %s\n", err.message);
+            assert_not_reached();
+        }
+    }
+
     public void edit_context_font() {
         string html = "<p id=\"test\" style=\"font-family: Comic Sans; font-size: 144\">para</p>";
         load_body_fixture(html);
 
         try {
             assert(run_javascript(@"new EditContext(document.getElementById('test')).encode()")
-                   == ("Comic Sans,144"));
+                   == ("0,,Comic Sans,144"));
         } catch (Geary.JS.Error err) {
             print("Geary.JS.Error: %s\n", err.message);
             assert_not_reached();
diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt
index c95061e..bc5c57d 100644
--- a/ui/CMakeLists.txt
+++ b/ui/CMakeLists.txt
@@ -8,6 +8,7 @@ set(RESOURCE_LIST
               "client-web-view.js"
               "client-web-view-allow-remote-images.js"
   STRIPBLANKS "composer-headerbar.ui"
+  STRIPBLANKS "composer-link-popover.ui"
   STRIPBLANKS "composer-menus.ui"
   STRIPBLANKS "composer-widget.ui"
               "composer-web-view.js"
diff --git a/ui/composer-link-popover.ui b/ui/composer-link-popover.ui
new file mode 100644
index 0000000..6ed29c7
--- /dev/null
+++ b/ui/composer-link-popover.ui
@@ -0,0 +1,130 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.20.0 -->
+<interface>
+  <requires lib="gtk+" version="3.14"/>
+  <template class="ComposerLinkPopover" parent="GtkPopover">
+    <property name="can_focus">False</property>
+    <property name="position">bottom</property>
+    <child>
+      <object class="GtkGrid">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="margin_left">6</property>
+        <property name="margin_right">6</property>
+        <property name="margin_top">6</property>
+        <property name="margin_bottom">6</property>
+        <property name="row_spacing">6</property>
+        <property name="column_spacing">6</property>
+        <child>
+          <object class="GtkLabel">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="label" translatable="yes">Link URL:</property>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkEntry" id="url">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="can_default">True</property>
+            <property name="width_chars">40</property>
+            <property name="primary_icon_activatable">False</property>
+            <property name="secondary_icon_activatable">False</property>
+            <property name="placeholder_text">http://</property>
+            <property name="input_purpose">url</property>
+            <signal name="activate" handler="on_activate_popover" swapped="no"/>
+            <signal name="changed" handler="on_url_changed" swapped="no"/>
+          </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="insert">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="tooltip_text" translatable="yes">Insert a new link with this URL</property>
+            <signal name="clicked" handler="on_activate_popover" swapped="no"/>
+            <child>
+              <object class="GtkImage">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="icon_name">emblem-ok-symbolic</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="left_attach">2</property>
+            <property name="top_attach">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="update">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="tooltip_text" translatable="yes">Update the link URL</property>
+            <signal name="clicked" handler="on_activate_popover" swapped="no"/>
+            <child>
+              <object class="GtkImage">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="icon_name">emblem-ok-symbolic</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="left_attach">3</property>
+            <property name="top_attach">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="delete">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="tooltip_text" translatable="yes">Delete this link</property>
+            <signal name="clicked" handler="on_delete_clicked" swapped="no"/>
+            <child>
+              <object class="GtkImage">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="icon_name">user-trash-symbolic</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="left_attach">4</property>
+            <property name="top_attach">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="open">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="tooltip_text" translatable="yes">Open this link</property>
+            <signal name="clicked" handler="on_open_clicked" swapped="no"/>
+            <child>
+              <object class="GtkImage">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="icon_name">document-open-symbolic</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="left_attach">5</property>
+            <property name="top_attach">0</property>
+          </packing>
+        </child>
+      </object>
+    </child>
+  </template>
+</interface>
diff --git a/ui/composer-web-view.js b/ui/composer-web-view.js
index 5922855..32f2dca 100644
--- a/ui/composer-web-view.js
+++ b/ui/composer-web-view.js
@@ -314,6 +314,14 @@ EditContext.LINK_MASK = 1 << 0;
 
 EditContext.prototype = {
     init: function(node) {
+        this.context = 0;
+        this.linkUrl = "";
+
+        if (node.nodeName == "A") {
+            this.context |=  EditContext.LINK_MASK;
+            this.linkUrl = node.href;
+        }
+
         let styles = window.getComputedStyle(node);
         let fontFamily = styles.getPropertyValue("font-family");
         if (fontFamily.charAt() == "'") {
@@ -324,11 +332,15 @@ EditContext.prototype = {
     },
     equals: function(other) {
         return other != null
+            && this.context == other.context
+            && this.linkUrl == other.linkUrl
             && this.fontFamily == other.fontFamily
             && this.fontSize == other.fontSize;
     },
     encode: function() {
         return [
+            this.context.toString(16),
+            this.linkUrl,
             this.fontFamily,
             this.fontSize
         ].join(",");


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]