From c476fdc6d1f02848e3f6ff3a6a8c2d929d5e4a64 Mon Sep 17 00:00:00 2001 From: Michael James Gratton Date: Thu, 19 Jan 2017 02:23:57 +1100 Subject: [PATCH] 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 + .../composer/composer-link-popover.vala | 189 ++++++++++++++++++ src/client/composer/composer-web-view.vala | 29 ++- src/client/composer/composer-widget.vala | 141 +++++-------- .../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(-) create mode 100644 src/client/composer/composer-link-popover.vala create mode 100644 ui/composer-link-popover.ui diff --git a/po/POTFILES.in b/po/POTFILES.in index 054ffb20..f5939d4c 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 a91beda8..bb0ad056 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 1c780a09..8452fba1 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 00000000..d51d312d --- /dev/null +++ b/src/client/composer/composer-link-popover.vala @@ -0,0 +1,189 @@ +/* + * Copyright 2017 Michael Gratton + * + * 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 79dc233c..5546b356 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]); - string view_name = values[0].down(); + this.link_url = values[1]; + + 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 dc716f0c..d9c0a422 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 * * 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 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 1ad2c062..5383e480 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 { } 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 09eef3c1..68b72791 100644 --- a/test/js/composer-page-state-test.vala +++ b/test/js/composer-page-state-test.vala @@ -10,6 +10,7 @@ class ComposerPageStateTest : ClientWebViewTestCase { 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 { add_test("replace_non_breaking_space", replace_non_breaking_space); } + public void edit_context_link() { + string html = "para"; + 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 = "

para

"; 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 c95061e9..bc5c57d1 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 00000000..6ed29c72 --- /dev/null +++ b/ui/composer-link-popover.ui @@ -0,0 +1,130 @@ + + + + + + diff --git a/ui/composer-web-view.js b/ui/composer-web-view.js index 5922855d..32f2dcac 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(",");