forked from Mirrors/apostrophe
Sync scroll between text view and web view
Scrolling is synced via scroll percentage. This works for most cases, but breaks down on very large or complex documents. It is consistent with the approach other editors use (eg. iA Writer), but in the future we should explore alternatives that don't incur in edge cases. The syncing itself is done via JavaScript. It could be argued that a `WebExtension` is the better approach, but it is considerably more complex for such a simple use case and it would be painful to implement until UberWriter's build system is updated, since it requires implementing a C extension. Fixes #55github/fork/yochananmarqos/patch-1
parent
63b20d0f3c
commit
562cc7e200
|
@ -1,48 +0,0 @@
|
|||
class Scroller:
|
||||
def __init__(self, scrolled_window, source_pos, target_pos):
|
||||
super().__init__()
|
||||
|
||||
self.scrolled_window = scrolled_window
|
||||
self.source_pos = source_pos
|
||||
self.target_pos = target_pos
|
||||
self.duration = max(200, (target_pos - source_pos) / 50) * 1000
|
||||
|
||||
self.is_started = False
|
||||
self.is_setup = False
|
||||
self.start_time = 0
|
||||
self.end_time = 0
|
||||
self.tick_callback_id = 0
|
||||
|
||||
def start(self):
|
||||
self.is_started = True
|
||||
self.tick_callback_id = self.scrolled_window.add_tick_callback(self.on_tick)
|
||||
|
||||
def end(self):
|
||||
self.scrolled_window.remove_tick_callback(self.tick_callback_id)
|
||||
self.is_started = False
|
||||
|
||||
def setup(self, time):
|
||||
self.start_time = time
|
||||
self.end_time = time + self.duration
|
||||
self.is_setup = True
|
||||
|
||||
def on_tick(self, widget, frame_clock):
|
||||
def ease_out_cubic(value):
|
||||
return pow(value - 1, 3) + 1
|
||||
|
||||
now = frame_clock.get_frame_time()
|
||||
if not self.is_setup:
|
||||
self.setup(now)
|
||||
|
||||
if now < self.end_time:
|
||||
time = float(now - self.start_time) / float(self.end_time - self.start_time)
|
||||
else:
|
||||
time = 1
|
||||
self.end()
|
||||
|
||||
time = ease_out_cubic(time)
|
||||
pos = self.source_pos + (time * (self.target_pos - self.source_pos))
|
||||
widget.get_vadjustment().props.value = pos
|
||||
return True
|
||||
|
||||
|
|
@ -3,9 +3,9 @@ import gi
|
|||
from uberwriter.inline_preview import InlinePreview
|
||||
from uberwriter.text_view_format_inserter import FormatInserter
|
||||
from uberwriter.text_view_markup_handler import MarkupHandler
|
||||
from uberwriter.text_view_scroller import TextViewScroller
|
||||
from uberwriter.text_view_undo_redo_handler import UndoRedoHandler
|
||||
from uberwriter.text_view_drag_drop_handler import DragDropHandler, TARGET_URI, TARGET_TEXT
|
||||
from uberwriter.scroller import Scroller
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
gi.require_version('Gspell', '1')
|
||||
|
@ -85,6 +85,7 @@ class TextView(Gtk.TextView):
|
|||
|
||||
# Scrolling
|
||||
self.scroller = None
|
||||
self.connect('parent-set', self.on_parent_set)
|
||||
self.get_buffer().connect('mark-set', self.on_mark_set)
|
||||
|
||||
# Focus mode
|
||||
|
@ -105,14 +106,41 @@ class TextView(Gtk.TextView):
|
|||
text_buffer = self.get_buffer()
|
||||
text_buffer.set_text(text)
|
||||
|
||||
def get_scroll_scale(self):
|
||||
return self.scroller.get_scroll_scale() if self.scroller else 0
|
||||
|
||||
def set_scroll_scale(self, scale):
|
||||
if self.scroller:
|
||||
self.scroller.set_scroll_scale(scale)
|
||||
|
||||
def on_text_changed(self, *_):
|
||||
self.markup.apply()
|
||||
GLib.idle_add(self.scroll_to)
|
||||
self.smooth_scroll_to()
|
||||
|
||||
def on_size_allocate(self, *_):
|
||||
self.update_vertical_margin()
|
||||
self.markup.update_margins_indents()
|
||||
|
||||
def on_parent_set(self, *_):
|
||||
parent = self.get_parent()
|
||||
if parent:
|
||||
self.scroller = TextViewScroller(self, parent)
|
||||
else:
|
||||
self.scroller = None
|
||||
|
||||
def on_mark_set(self, _text_buffer, _location, mark, _data=None):
|
||||
if mark.get_name() == 'insert':
|
||||
self.markup.apply()
|
||||
self.smooth_scroll_to(mark)
|
||||
elif mark.get_name() == 'gtk_drag_target':
|
||||
self.smooth_scroll_to(mark)
|
||||
return True
|
||||
|
||||
def on_button_release_event(self, _widget, _event):
|
||||
if self.focus_mode:
|
||||
self.markup.apply()
|
||||
return False
|
||||
|
||||
def set_focus_mode(self, focus_mode):
|
||||
"""Toggle focus mode.
|
||||
|
||||
|
@ -123,7 +151,7 @@ class TextView(Gtk.TextView):
|
|||
self.gspell_view.set_inline_spell_checking(not focus_mode)
|
||||
self.update_vertical_margin()
|
||||
self.markup.apply()
|
||||
self.scroll_to()
|
||||
self.smooth_scroll_to()
|
||||
|
||||
def update_vertical_margin(self):
|
||||
if self.focus_mode:
|
||||
|
@ -134,11 +162,6 @@ class TextView(Gtk.TextView):
|
|||
self.props.top_margin = 80
|
||||
self.props.bottom_margin = 64
|
||||
|
||||
def on_button_release_event(self, _widget, _event):
|
||||
if self.focus_mode:
|
||||
self.markup.apply()
|
||||
return False
|
||||
|
||||
def set_hemingway_mode(self, hemingway_mode):
|
||||
"""Toggle hemingway mode.
|
||||
|
||||
|
@ -158,48 +181,13 @@ class TextView(Gtk.TextView):
|
|||
self.get_buffer().set_text('')
|
||||
self.undo_redo.clear()
|
||||
|
||||
def scroll_to(self, mark=None):
|
||||
def smooth_scroll_to(self, mark=None):
|
||||
"""Scrolls if needed to ensure mark is visible.
|
||||
|
||||
If mark is unspecified, the cursor is used."""
|
||||
|
||||
margin = 32
|
||||
scrolled_window = self.get_ancestor(Gtk.ScrolledWindow.__gtype__)
|
||||
if not scrolled_window:
|
||||
if self.scroller is None:
|
||||
return
|
||||
va = scrolled_window.get_vadjustment()
|
||||
if va.props.page_size < margin * 2:
|
||||
return
|
||||
|
||||
text_buffer = self.get_buffer()
|
||||
if mark:
|
||||
mark_iter = text_buffer.get_iter_at_mark(mark)
|
||||
else:
|
||||
mark_iter = text_buffer.get_iter_at_mark(text_buffer.get_insert())
|
||||
mark_rect = self.get_iter_location(mark_iter)
|
||||
|
||||
pos_y = mark_rect.y + mark_rect.height + self.props.top_margin
|
||||
pos = pos_y - va.props.value
|
||||
target_pos = None
|
||||
if self.focus_mode:
|
||||
if pos != (va.props.page_size * 0.5):
|
||||
target_pos = pos_y - (va.props.page_size * 0.5)
|
||||
elif pos > va.props.page_size - margin:
|
||||
target_pos = pos_y - va.props.page_size + margin
|
||||
elif pos < margin:
|
||||
target_pos = pos_y - margin
|
||||
|
||||
if self.scroller and self.scroller.is_started:
|
||||
self.scroller.end()
|
||||
if target_pos:
|
||||
self.scroller = Scroller(scrolled_window, va.props.value, target_pos)
|
||||
self.scroller.start()
|
||||
|
||||
def on_mark_set(self, _text_buffer, _location, mark, _data=None):
|
||||
if mark.get_name() == 'insert':
|
||||
self.markup.apply()
|
||||
if self.focus_mode:
|
||||
self.scroll_to(mark)
|
||||
elif mark.get_name() == 'gtk_drag_target':
|
||||
self.scroll_to(mark)
|
||||
return True
|
||||
if mark is None:
|
||||
mark = self.get_buffer().get_insert()
|
||||
GLib.idle_add(self.scroller.smooth_scroll_to_mark, mark, self.focus_mode)
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
from gi.repository import Gtk
|
||||
|
||||
|
||||
class TextViewScroller:
|
||||
def __init__(self, text_view, scrolled_window):
|
||||
super().__init__()
|
||||
|
||||
self.text_view = text_view
|
||||
self.scrolled_window = scrolled_window
|
||||
self.smooth_scroller = None
|
||||
|
||||
def get_scroll_scale(self):
|
||||
vap = self.scrolled_window.get_vadjustment().props
|
||||
if vap.upper > vap.page_size:
|
||||
return vap.value / (vap.upper - vap.page_size)
|
||||
else:
|
||||
return 0
|
||||
|
||||
def set_scroll_scale(self, scale):
|
||||
vap = self.scrolled_window.get_vadjustment().props
|
||||
vap.value = (vap.upper - vap.page_size) * scale
|
||||
|
||||
def scroll_to_mark(self, mark, center):
|
||||
"""Scrolls until mark is visible, if needed."""
|
||||
|
||||
target_pos = self.get_target_pos_for_mark(mark, center)
|
||||
if target_pos:
|
||||
self.scrolled_window.get_vadjustment().set_value(target_pos)
|
||||
|
||||
def smooth_scroll_to_mark(self, mark, center):
|
||||
"""Smoothly scrolls until mark is visible, if needed."""
|
||||
|
||||
if self.smooth_scroller and self.smooth_scroller.is_started:
|
||||
self.smooth_scroller.end()
|
||||
|
||||
target_pos = self.get_target_pos_for_mark(mark, center)
|
||||
if target_pos:
|
||||
source_pos = self.scrolled_window.get_vadjustment().props.value
|
||||
self.smooth_scroller = SmoothScroller(self.scrolled_window, source_pos, target_pos)
|
||||
self.smooth_scroller.start()
|
||||
|
||||
def get_target_pos_for_mark(self, mark, center):
|
||||
margin = 32
|
||||
|
||||
mark_iter = self.text_view.get_buffer().get_iter_at_mark(mark)
|
||||
mark_rect = self.text_view.get_iter_location(mark_iter)
|
||||
|
||||
vap = self.scrolled_window.get_vadjustment().props
|
||||
|
||||
pos_y = mark_rect.y + mark_rect.height + self.text_view.props.top_margin
|
||||
pos_viewport_y = pos_y - vap.value
|
||||
target_pos = None
|
||||
if center:
|
||||
if pos_viewport_y != vap.page_size / 2:
|
||||
target_pos = pos_y - (vap.page_size / 2)
|
||||
elif pos_viewport_y > vap.page_size - margin:
|
||||
target_pos = pos_y - vap.page_size + margin
|
||||
elif pos_viewport_y < margin:
|
||||
target_pos = pos_y - margin - mark_rect.height
|
||||
|
||||
return target_pos
|
||||
|
||||
|
||||
class SmoothScroller:
|
||||
def __init__(self, scrolled_window, source_pos, target_pos):
|
||||
super().__init__()
|
||||
|
||||
self.scrolled_window = scrolled_window
|
||||
self.source_pos = source_pos
|
||||
self.target_pos = target_pos
|
||||
self.duration = max(100, (target_pos - source_pos) / 50) * 1000
|
||||
|
||||
self.is_started = False
|
||||
self.is_setup = False
|
||||
self.start_time = 0
|
||||
self.end_time = 0
|
||||
self.tick_callback_id = 0
|
||||
|
||||
def start(self):
|
||||
self.is_started = True
|
||||
self.tick_callback_id = self.scrolled_window.add_tick_callback(self.on_tick)
|
||||
|
||||
def end(self):
|
||||
self.scrolled_window.remove_tick_callback(self.tick_callback_id)
|
||||
self.is_started = False
|
||||
|
||||
def setup(self, time):
|
||||
self.start_time = time
|
||||
self.end_time = time + self.duration
|
||||
self.is_setup = True
|
||||
|
||||
def on_tick(self, widget, frame_clock):
|
||||
def ease_out_cubic(value):
|
||||
return pow(value - 1, 3) + 1
|
||||
|
||||
now = frame_clock.get_frame_time()
|
||||
if not self.is_setup:
|
||||
self.setup(now)
|
||||
|
||||
if now < self.end_time:
|
||||
time = float(now - self.start_time) / float(self.end_time - self.start_time)
|
||||
else:
|
||||
time = 1
|
||||
self.end()
|
||||
|
||||
time = ease_out_cubic(time)
|
||||
pos = self.source_pos + (time * (self.target_pos - self.source_pos))
|
||||
widget.get_vadjustment().props.value = pos
|
||||
return True
|
|
@ -0,0 +1,134 @@
|
|||
import gi
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
gi.require_version('WebKit2', '4.0')
|
||||
from gi.repository import WebKit2, GLib
|
||||
|
||||
|
||||
class WebViewScroller:
|
||||
"""Provides read/write scrolling functionality to a WebView.
|
||||
|
||||
It does so using JavaScript, by continuously monitoring it while loaded and focused.
|
||||
The alternative is using a WebExtension and C-bindings (see reference), but that is more
|
||||
complicated implementation-wise, and build-wise, at least we start building with Meson.
|
||||
|
||||
Reference: https://github.com/aperezdc/webkit2gtk-python-webextension-example
|
||||
"""
|
||||
|
||||
SETUP_SROLL_SCALE_JS = """
|
||||
const e = document.documentElement;
|
||||
function get_scroll_scale() {
|
||||
if (document.readyState !== "complete" || e.clientHeight === 0) {
|
||||
return null;
|
||||
} else if (e.scrollHeight <= e.clientHeight) {
|
||||
return 0;
|
||||
} else {
|
||||
return e.scrollTop / (e.scrollHeight - e.clientHeight);
|
||||
}
|
||||
}
|
||||
function set_scroll_scale(scale) {
|
||||
if (document.readyState !== "complete") {
|
||||
window.addEventListener("load", function() { set_scroll_scale(scale); });
|
||||
} else if (e.clientHeight === 0) {
|
||||
window.addEventListener("resize", function() { set_scroll_scale(scale); });
|
||||
} else if (e.scrollHeight > e.clientHeight) {
|
||||
e.scrollTop = (e.scrollHeight - e.clientHeight) * scale;
|
||||
}
|
||||
return get_scroll_scale();
|
||||
}
|
||||
get_scroll_scale();
|
||||
""".strip()
|
||||
|
||||
GET_SCROLL_SCALE_JS = "get_scroll_scale();"
|
||||
|
||||
SET_SCROLL_SCALE_JS = "set_scroll_scale({:.16f});"
|
||||
|
||||
def __init__(self, webview):
|
||||
super().__init__()
|
||||
|
||||
self.webview = webview
|
||||
|
||||
self.webview.connect("focus-in-event", self.on_focus_changed)
|
||||
self.webview.connect("focus-out-event", self.on_focus_changed)
|
||||
self.webview.connect("load-changed", self.on_load_changed)
|
||||
self.webview.connect("destroy", self.on_destroy)
|
||||
|
||||
self.scroll_scale = 0
|
||||
|
||||
self.state_loaded = False
|
||||
self.state_setup = False
|
||||
self.state_focused = True
|
||||
self.state_dirty = False
|
||||
self.state_waiting = False
|
||||
|
||||
self.timeout_id = None
|
||||
|
||||
def get_scroll_scale(self):
|
||||
return self.scroll_scale
|
||||
|
||||
def set_scroll_scale(self, scale):
|
||||
self.scroll_scale = scale
|
||||
self.state_dirty = True
|
||||
self.state_loop()
|
||||
|
||||
def on_focus_changed(self, _webview, event):
|
||||
self.state_focused = event.in_
|
||||
self.state_loop()
|
||||
|
||||
def on_load_changed(self, _webview, event):
|
||||
self.state_loaded = event == WebKit2.LoadEvent.FINISHED
|
||||
self.state_loop()
|
||||
|
||||
def on_destroy(self, _widget):
|
||||
self.state_loaded = False
|
||||
self.state_focused = False
|
||||
self.state_loop()
|
||||
self.webview = None
|
||||
|
||||
def setup_scroll_state(self):
|
||||
self.state_waiting = True
|
||||
self.state_setup = True
|
||||
self.webview.run_javascript(
|
||||
self.SETUP_SROLL_SCALE_JS, None, self.sync_scroll_scale)
|
||||
|
||||
def read_scroll_scale(self):
|
||||
self.state_waiting = True
|
||||
self.webview.run_javascript(
|
||||
self.GET_SCROLL_SCALE_JS, None, self.sync_scroll_scale)
|
||||
|
||||
def write_scroll_scale(self):
|
||||
self.state_waiting = True
|
||||
self.state_dirty = False
|
||||
self.webview.run_javascript(
|
||||
self.SET_SCROLL_SCALE_JS.format(self.scroll_scale), None, self.sync_scroll_scale)
|
||||
|
||||
def sync_scroll_scale(self, _webview, result):
|
||||
self.state_waiting = False
|
||||
result = self.webview.run_javascript_finish(result)
|
||||
self.state_loop(result.get_js_value().to_double())
|
||||
|
||||
def state_loop(self, scroll_scale=None, read_delay=500):
|
||||
# Remove any pending callbacks
|
||||
if self.timeout_id:
|
||||
GLib.source_remove(self.timeout_id)
|
||||
self.timeout_id = None
|
||||
|
||||
# Set scroll scale if specified, and the state is not dirty
|
||||
if scroll_scale is not None and not self.state_dirty:
|
||||
self.scroll_scale = scroll_scale
|
||||
|
||||
# Handle the current state
|
||||
if self.state_waiting:
|
||||
return
|
||||
elif not self.state_loaded:
|
||||
return
|
||||
elif not self.state_setup:
|
||||
self.setup_scroll_state()
|
||||
elif self.state_dirty:
|
||||
self.write_scroll_scale()
|
||||
elif self.state_focused:
|
||||
if read_delay > 0:
|
||||
self.timeout_id = GLib.timeout_add(read_delay, self.state_loop, None, 0)
|
||||
else:
|
||||
self.read_scroll_scale()
|
||||
|
|
@ -27,6 +27,7 @@ import gi
|
|||
from uberwriter.export_dialog import Export
|
||||
from uberwriter.stats_handler import StatsHandler
|
||||
from uberwriter.text_view import TextView
|
||||
from uberwriter.web_view_scroller import WebViewScroller
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
gi.require_version('WebKit2', '4.0') # pylint: disable=wrong-import-position
|
||||
|
@ -108,7 +109,8 @@ class Window(Gtk.ApplicationWindow):
|
|||
self.text_view.grab_focus()
|
||||
|
||||
# Setup preview webview
|
||||
self.preview_webview = None
|
||||
self.web_view = None
|
||||
self.web_view_scroller = None
|
||||
|
||||
self.scrolled_window = self.builder.get_object('editor_scrolledwindow')
|
||||
self.scrolled_window.get_style_context().add_class('uberwriter-scrolled-window')
|
||||
|
@ -503,25 +505,36 @@ class Window(Gtk.ApplicationWindow):
|
|||
"""
|
||||
|
||||
if state.get_boolean():
|
||||
self.show_preview()
|
||||
self.show_web_view()
|
||||
else:
|
||||
self.show_text_editor()
|
||||
|
||||
return True
|
||||
|
||||
def show_text_editor(self):
|
||||
# Swap web view with text view
|
||||
self.scrolled_window.remove(self.scrolled_window.get_child())
|
||||
self.scrolled_window.add(self.text_view)
|
||||
self.text_view.show()
|
||||
self.preview_webview.destroy()
|
||||
self.preview_webview = None
|
||||
self.queue_draw()
|
||||
|
||||
def show_preview(self, loaded=False):
|
||||
# Sync scroll between web view and text view
|
||||
self.text_view.set_scroll_scale(self.web_view_scroller.get_scroll_scale())
|
||||
|
||||
# Destroy web view to clean up resources
|
||||
self.web_view.destroy()
|
||||
self.web_view = None
|
||||
self.web_view_scroller = None
|
||||
|
||||
def show_web_view(self, loaded=False):
|
||||
if loaded:
|
||||
# Sync scroll between text view and web view
|
||||
self.web_view_scroller.set_scroll_scale(self.text_view.get_scroll_scale())
|
||||
|
||||
# Swap text view with web view
|
||||
self.scrolled_window.remove(self.scrolled_window.get_child())
|
||||
self.scrolled_window.add(self.preview_webview)
|
||||
self.preview_webview.show()
|
||||
self.scrolled_window.add(self.web_view)
|
||||
self.web_view.show()
|
||||
self.queue_draw()
|
||||
else:
|
||||
args = ['--standalone',
|
||||
|
@ -531,22 +544,23 @@ class Window(Gtk.ApplicationWindow):
|
|||
'--lua-filter=' + helpers.get_script_path('task-list.lua')]
|
||||
output = helpers.pandoc_convert(self.text_view.get_text(), to="html5", args=args)
|
||||
|
||||
if self.preview_webview is None:
|
||||
self.preview_webview = WebKit.WebView()
|
||||
self.preview_webview.get_settings().set_allow_universal_access_from_file_urls(True)
|
||||
if self.web_view is None:
|
||||
self.web_view = WebKit.WebView()
|
||||
self.web_view.get_settings().set_allow_universal_access_from_file_urls(True)
|
||||
self.web_view_scroller = WebViewScroller(self.web_view)
|
||||
|
||||
# Show preview once the load is finished
|
||||
self.preview_webview.connect("load-changed", self.on_preview_load_change)
|
||||
self.web_view.connect("load-changed", self.on_preview_load_change)
|
||||
|
||||
# This saying that all links will be opened in default browser, \
|
||||
# but local files are opened in appropriate apps:
|
||||
self.preview_webview.connect("decide-policy", self.on_click_link)
|
||||
self.web_view.connect("decide-policy", self.on_click_link)
|
||||
|
||||
self.preview_webview.load_html(output, 'file://localhost/')
|
||||
self.web_view.load_html(output, 'file://localhost/')
|
||||
|
||||
def reload_preview(self):
|
||||
if self.preview_webview:
|
||||
self.show_preview()
|
||||
if self.web_view:
|
||||
self.show_web_view()
|
||||
|
||||
def load_file(self, filename=None):
|
||||
"""Open File from command line or open / open recent etc."""
|
||||
|
@ -726,11 +740,11 @@ class Window(Gtk.ApplicationWindow):
|
|||
base_path = "/"
|
||||
self.settings.set_value("open-file-path", GLib.Variant("s", base_path))
|
||||
|
||||
def on_preview_load_change(self, webview, event):
|
||||
def on_preview_load_change(self, _web_view, event):
|
||||
"""swaps text editor with preview once the load is complete
|
||||
"""
|
||||
if event == WebKit.LoadEvent.FINISHED:
|
||||
self.show_preview(loaded=True)
|
||||
self.show_web_view(loaded=True)
|
||||
|
||||
def on_click_link(self, web_view, decision, _decision_type):
|
||||
"""provide ability for self.webview to open links in default browser
|
||||
|
|
Loading…
Reference in New Issue