New scrolling system for inline composers

The editor in the composer no longer shows its own scrollbar.  Instead,
the conversation view allocates enough space to hold the composer
without any scrolling.  The composer then positions and scrolls itself
to create the illusion that the outer scroll bar controls it.

To track the size of the composer, we put all the text in a div and mark
that as contenteditable.  Then, when the div changes height, we update
the layout.  We use the user_changed_contents signal as a proxy for
this.

https://bugzilla.gnome.org/show_bug.cgi?id=730955
This commit is contained in:
Robert Schroll 2014-06-11 13:12:04 -04:00
parent 1a2f693e43
commit c36ff017e6
6 changed files with 197 additions and 25 deletions

View file

@ -6,10 +6,17 @@
public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
private const int MIN_EDITOR_HEIGHT = 200;
private ComposerWidget composer;
private ConversationViewer conversation_viewer;
private Gee.Set<Geary.App.Conversation>? prev_selection = null;
private string embed_id;
private bool setting_inner_scroll;
private bool scrolled_to_bottom = false;
private double inner_scroll_adj_value;
private int inner_view_height;
private int min_height = MIN_EDITOR_HEIGHT;
public Gtk.Window top_window {
get { return (Gtk.Window) get_toplevel(); }
@ -49,14 +56,64 @@ public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
}
add(composer);
realize.connect(update_style);
realize.connect(on_realize);
composer.editor.focus_in_event.connect(on_focus_in);
composer.editor.focus_out_event.connect(on_focus_out);
composer.editor.document_load_finished.connect(on_loaded);
conversation_viewer.compose_overlay.add_overlay(this);
show();
present();
}
private void on_realize() {
update_style();
if (composer.state != ComposerWidget.ComposerState.INLINE_NEW) {
Gtk.ScrolledWindow win = (Gtk.ScrolledWindow) composer.editor.parent;
win.get_vscrollbar().hide();
composer.editor.vadjustment.value_changed.connect(on_inner_scroll);
composer.editor.vadjustment.changed.connect(on_adjust_changed);
composer.editor.user_changed_contents.connect(on_inner_size_changed);
reroute_scroll_handling(this);
}
}
private void on_loaded() {
if (composer.state != ComposerWidget.ComposerState.INLINE_NEW) {
try {
composer.editor.get_dom_document().body.get_class_list().add("embedded");
} catch (Error error) {
debug("Error setting class of editor: %s", error.message);
}
Idle.add(() => {
recalc_height();
conversation_viewer.compose_overlay.queue_resize();
return false;
});
}
}
private void reroute_scroll_handling(Gtk.Widget widget) {
widget.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK);
widget.scroll_event.connect(on_inner_scroll_event);
Gtk.Container? container = widget as Gtk.Container;
if (container != null) {
foreach (Gtk.Widget child in container.get_children())
reroute_scroll_handling(child);
}
}
private void disable_scroll_reroute(Gtk.Widget widget) {
widget.scroll_event.disconnect(on_inner_scroll_event);
Gtk.Container? container = widget as Gtk.Container;
if (container != null) {
foreach (Gtk.Widget child in container.get_children())
disable_scroll_reroute(child);
}
}
private void update_style() {
Gdk.RGBA window_background = top_window.get_style_context()
.get_background_color(Gtk.StateFlags.NORMAL);
@ -76,8 +133,19 @@ public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
on_focus_out();
composer.editor.focus_in_event.disconnect(on_focus_in);
composer.editor.focus_out_event.disconnect(on_focus_out);
composer.editor.vadjustment.value_changed.disconnect(on_inner_scroll);
composer.editor.user_changed_contents.disconnect(on_inner_size_changed);
disable_scroll_reroute(this);
Gtk.ScrolledWindow win = (Gtk.ScrolledWindow) composer.editor.parent;
win.get_vscrollbar().show();
Gtk.Widget focus = top_window.get_focus();
try {
composer.editor.get_dom_document().body.get_class_list().remove("embedded");
} catch (Error error) {
debug("Error setting class of editor: %s", error.message);
}
remove(composer);
ComposerWindow window = new ComposerWindow(composer);
if (focus != null) {
@ -90,15 +158,50 @@ public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
close_container();
}
public bool set_position(ref Gdk.Rectangle allocation, double hscroll, double vscroll) {
public bool set_position(ref Gdk.Rectangle allocation, double hscroll, double vscroll,
int view_height) {
WebKit.DOM.Element embed = conversation_viewer.web_view.get_dom_document().get_element_by_id(embed_id);
if (embed == null)
return false;
int div_height = (int) embed.client_height;
int y_top = (int) (embed.offset_top + embed.client_top) - (int) vscroll;
int available_height = int.min(y_top + div_height, view_height) - int.max(y_top, 0);
if (available_height < 0 || available_height == div_height ||
composer.state == ComposerWidget.ComposerState.INLINE_NEW) {
// It fits in the available space, or it doesn't fit at all
allocation.y = y_top;
// When offscreen, make it very small to ensure scrolling during any edit
allocation.height = (available_height < 0) ? 1 : div_height;
} else if (available_height > min_height) {
// There's enough room, so make sure we get the whole widget in
allocation.y = int.max(y_top, 0);
allocation.height = available_height;
} else {
// Minimum height widget, placed so as much as possible is visible
allocation.y = int.max(y_top, int.min(y_top + div_height - min_height, 0));
allocation.height = min_height;
}
allocation.x = (int) (embed.offset_left + embed.client_left) - (int) hscroll;
allocation.y = (int) (embed.offset_top + embed.client_top) - (int) vscroll;
allocation.width = (int) embed.client_width;
allocation.height = (int) embed.client_height;
// INLINE_NEW handles its own scrolling.
if (composer.state == ComposerWidget.ComposerState.INLINE_NEW)
return true;
// Work out adjustment of composer web view
setting_inner_scroll = true;
composer.editor.vadjustment.set_value(allocation.y - y_top);
setting_inner_scroll = false;
// This sets the scroll before the widget gets resized. Although the adjustment
// may be scrolled to the bottom right now, the current value may not do that
// once the widget is shrunk; for example, while scrolling down the page past
// the bottom of the editor. So if we're at the bottom, record that fact. When
// the limits of the adjustment are changed (watched by on_adjust_changed), we
// can keep it at the bottom.
scrolled_to_bottom = (y_top <= 0 && available_height < view_height);
return true;
}
@ -112,9 +215,66 @@ public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
return false;
}
private void on_inner_scroll(Gtk.Adjustment adj) {
double delta = adj.value - inner_scroll_adj_value;
inner_scroll_adj_value = adj.value;
if (delta != 0 && !setting_inner_scroll) {
Gtk.Adjustment outer_adj = conversation_viewer.web_view.vadjustment;
outer_adj.set_value(outer_adj.value + delta);
}
}
private void on_adjust_changed(Gtk.Adjustment adj) {
if (scrolled_to_bottom) {
setting_inner_scroll = true;
adj.set_value(adj.upper);
setting_inner_scroll = false;
}
}
private void on_inner_size_changed() {
scrolled_to_bottom = false; // The inserted character may cause a desired scroll
Idle.add(recalc_height); // So that this runs after the character has been inserted
}
private bool recalc_height() {
int view_height,
base_height = get_allocated_height() - composer.editor.get_allocated_height();
try {
view_height = (int) composer.editor.get_dom_document()
.query_selector("#message-body").offset_height;
} catch (Error error) {
debug("Error getting height of editor: %s", error.message);
return false;
}
if (view_height != inner_view_height || min_height != base_height + MIN_EDITOR_HEIGHT) {
inner_view_height = view_height;
min_height = base_height + MIN_EDITOR_HEIGHT;
// Calculate height widget should be to avoid scrolling in editor
int widget_height = int.max(view_height + base_height - 2, min_height); //? about 2
WebKit.DOM.Element embed = conversation_viewer.web_view
.get_dom_document().get_element_by_id(embed_id);
if (embed != null) {
try {
embed.style.set_property("height", @"$widget_height", "");
} catch (Error error) {
debug("Error setting height of composer widget");
}
}
}
return false;
}
private bool on_inner_scroll_event(Gdk.EventScroll event) {
conversation_viewer.web_view.scroll_event(event);
return true;
}
public void present() {
top_window.present();
conversation_viewer.web_view.get_dom_document().get_element_by_id(embed_id).scroll_into_view(true);
conversation_viewer.web_view.get_dom_document().get_element_by_id(embed_id)
.scroll_into_view_if_needed(false);
}
public unowned Gtk.Widget get_focus() {

View file

@ -65,7 +65,7 @@ public class ComposerWidget : Gtk.EventBox {
<html><head><title></title>
<style>
body {
margin: 10px !important;
margin: 0px !important;
padding: 0 !important;
background-color: white !important;
font-size: medium !important;
@ -81,6 +81,15 @@ public class ComposerWidget : Gtk.EventBox {
body.plain a {
cursor: text;
}
#message-body {
box-sizing: border-box;
padding: 10px;
outline: 0px solid transparent;
min-height: 100%;
}
.embedded #message-body {
min-height: 200px;
}
blockquote {
margin-top: 0px;
margin-bottom: 0px;
@ -97,7 +106,9 @@ public class ComposerWidget : Gtk.EventBox {
margin: 0;
}
</style>
</head><body id="message-body"></body></html>""";
</head><body>
<div id="message-body" contenteditable="true"></div>
</body></html>""";
private const int DRAFT_TIMEOUT_MSEC = 2000; // 2 seconds
@ -262,12 +273,6 @@ public class ComposerWidget : Gtk.EventBox {
});
}
notify["state"].connect((s, p) => { update_from_field(); });
// Set the visibilities later, after show_all is called on the widget.
Idle.add(() => {
state = state; // Triggers visibilities
show_attachments();
return false;
});
from_label = (Gtk.Label) builder.get_object("from label");
from_single = (Gtk.Label) builder.get_object("from_single");
@ -444,7 +449,6 @@ public class ComposerWidget : Gtk.EventBox {
editor = new StylishWebView();
edit_fixer = new WebViewEditFixer(editor);
editor.editable = true;
editor.load_finished.connect(on_load_finished);
editor.hovering_over_link.connect(on_hovering_over_link);
editor.context_menu.connect(on_context_menu);
@ -595,10 +599,11 @@ public class ComposerWidget : Gtk.EventBox {
debug("Failed to load prefilled body: %s", e.message);
}
}
body.focus(); // Focus within the HTML document
protect_blockquote_styles();
set_focus();
set_focus(); // Focus in the GTK widget hierarchy
// Ensure the editor is in correct mode re HTML
on_compose_as_html();
@ -731,7 +736,10 @@ public class ComposerWidget : Gtk.EventBox {
public override void show_all() {
base.show_all();
// Now, hide elements that we don't want shown
update_from_field();
state = state; // Triggers visibilities
show_attachments();
}
public void change_compose_type(ComposeType new_type) {
@ -1554,11 +1562,13 @@ public class ComposerWidget : Gtk.EventBox {
}
private string get_html() {
return editor.get_dom_document().get_body().get_inner_html();
return ((WebKit.DOM.HTMLElement) editor.get_dom_document().get_element_by_id(BODY_ID))
.get_inner_html();
}
private string get_text() {
return html_to_flowed_text(editor.get_dom_document());
return html_to_flowed_text((WebKit.DOM.HTMLElement) editor.get_dom_document()
.get_element_by_id(BODY_ID));
}
private bool on_navigation_policy_decision_requested(WebKit.WebFrame frame,

View file

@ -37,7 +37,8 @@ public class ScrollableOverlay : Gtk.Overlay, Gtk.Scrollable {
}
private bool on_child_position(Gtk.Widget widget, Gdk.Rectangle allocation) {
return ((ComposerEmbed) widget).set_position(ref allocation, hadjustment.value, vadjustment.value);
return ((ComposerEmbed) widget).set_position(ref allocation, hadjustment.value,
vadjustment.value, get_allocated_height());
}
private void on_scroll() {

View file

@ -43,6 +43,7 @@ public class ConversationWebView : StylishWebView {
new_window_policy_decision_requested.connect(on_navigation_policy_decision_requested);
web_inspector.inspect_web_view.connect(activate_inspector);
document_font_changed.connect(on_document_font_changed);
scroll_event.connect(on_scroll_event);
// Load the HTML into WebKit.
// Note: load_finished signal MUST be hooked up before this call.
@ -55,7 +56,7 @@ public class ConversationWebView : StylishWebView {
return false;
}
public override bool scroll_event(Gdk.EventScroll event) {
private bool on_scroll_event(Gdk.EventScroll event) {
if ((event.state & Gdk.ModifierType.CONTROL_MASK) != 0) {
double dir = 0;
if (event.direction == Gdk.ScrollDirection.UP)

View file

@ -293,11 +293,11 @@ public string decorate_quotes(string text) throws Error {
}
// This will modify/reset the DOM
public string html_to_flowed_text(WebKit.DOM.Document doc) {
string saved_doc = doc.get_body().get_inner_html();
public string html_to_flowed_text(WebKit.DOM.HTMLElement el) {
string saved_doc = el.get_inner_html();
WebKit.DOM.NodeList blockquotes;
try {
blockquotes = doc.query_selector_all("blockquote");
blockquotes = el.query_selector_all("blockquote");
} catch (Error error) {
debug("Error selecting blockquotes: %s", error.message);
return "";
@ -326,11 +326,11 @@ public string html_to_flowed_text(WebKit.DOM.Document doc) {
}
// Reassemble plain text out of parts, replace non-breaking space with regular space
string doctext = resolve_nesting(doc.get_body().get_inner_text(), bqtexts).replace("\xc2\xa0", " ");
string doctext = resolve_nesting(el.get_inner_text(), bqtexts).replace("\xc2\xa0", " ");
// Reassemble DOM
try {
doc.get_body().set_inner_html(saved_doc);
el.set_inner_html(saved_doc);
} catch (Error error) {
debug("Error resetting DOM: %s", error.message);
}

View file

@ -120,7 +120,7 @@ hr {
left: auto;
right: auto;
width: 100%;
height: 600px;
height: 300px;
}
.email.sent {