From 04929a943d2afd1b6376919d13464dcb8d097f2e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 7 Apr 2016 15:18:27 +0530 Subject: [PATCH] Implement keyboard and wheel scrolling in flow mode --- src/pyj/keycodes.pyj | 54 ++++++++++++ src/pyj/read_book/flow_mode.pyj | 147 ++++++++++++++++++++------------ src/pyj/read_book/iframe.pyj | 34 +++++--- src/pyj/read_book/view.pyj | 13 ++- src/pyj/utils.pyj | 8 ++ 5 files changed, 185 insertions(+), 71 deletions(-) create mode 100644 src/pyj/keycodes.pyj diff --git a/src/pyj/keycodes.pyj b/src/pyj/keycodes.pyj new file mode 100644 index 0000000000..66ecc8dd0a --- /dev/null +++ b/src/pyj/keycodes.pyj @@ -0,0 +1,54 @@ +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2016, Kovid Goyal + +KEYCODE_MAP = K = Object.create(None) +K[8] = 'backspace' +K[9] = 'tab' +K[13] = 'enter' +K[16] = 'shift' +K[17] = 'ctrl' +K[18] = 'alt' +K[19] = 'pause' +K[20] = 'capslock' +K[27] = 'escape' +K[32] = 'space' +K[33] = 'pageup' +K[34] = 'pagedown' +K[35] = 'end' +K[36] = 'home' +K[37] = 'left' +K[38] = 'up' +K[39] = 'right' +K[40] = 'down' +K[45] = 'insert' +K[46] = 'delete' +K[91] = 'meta_l' +K[92] = 'meta_r' +K[93] = 'select' +K[106] = 'numpad*' +K[107] = 'numpad+' +K[109] = 'numpad-' +K[111] = 'numpad/' +K[144] = 'numlock' +K[145] = 'scrolllock' +K[186] = ';' +K[190] = '.' +K[191] = '/' +K[192] = '`' +K[219] = '[' +K[220] = '\\' +K[221] = ']' +K[222] = "'" + +for i in range(10): + KEYCODE_MAP[48 + i] = i + '' + KEYCODE_MAP[96 + i] = 'numpad' + i + +for i, c in enumerate(str.ascii_lowercase): + KEYCODE_MAP[65 + i] = c + +for i in range(1, 13): + KEYCODE_MAP[111 + i] = 'f' + 1 + +def get_key(key_event): + return KEYCODE_MAP[key_event.keyCode] diff --git a/src/pyj/read_book/flow_mode.pyj b/src/pyj/read_book/flow_mode.pyj index ec93522dea..aa8c4c905e 100644 --- a/src/pyj/read_book/flow_mode.pyj +++ b/src/pyj/read_book/flow_mode.pyj @@ -1,60 +1,99 @@ # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2016, Kovid Goyal -from dom import build_rule -from elementmaker import E -from gettext import gettext as _ -from read_book.globals import current_layout_mode, current_spine_item, uid, get_boss - -flow_previous_indicator = flow_next_indicator = None - -def document_height(): - html = document.documentElement - return max(document.body.scrollHeight, document.body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight) - -def setup_flow_indicators(style): - nonlocal flow_previous_indicator, flow_next_indicator - sel = 'flow-indicator-' + uid - flow_previous_indicator = E.div( - style='right:0; top:0;', '⬅ ' + _('prev page'), title=_('Go to the previous page'), - onclick=def(): - get_boss().send_message('next_spine_item', previous=True) - ) - flow_next_indicator = E.div( - style='right:0; bottom:0;', _('next page') + ' ➡', title=_('Go to the next page'), - onclick=def(): - get_boss().send_message('next_spine_item', previous=False) - ) - for c in [flow_next_indicator, flow_previous_indicator]: - c.setAttribute('class', sel) - document.body.appendChild(c) - document.body.appendChild(flow_previous_indicator) - document.body.appendChild(flow_next_indicator) - update_flow_mode_scroll_indicators() - style.push(build_rule( - '.' + sel, position='fixed', z_index='2147483647', padding='1ex 1em', margin='1em', border_radius='8px', - background_color='#eee', color='#0070FF', cursor='pointer', font_size='larger', font_family='sans-serif', - transition='opacity 0.5s ease-in', opacity='0' - )) - style.push(build_rule('.' + sel + ':hover', color='red')) - -def update_flow_mode_scroll_indicators(): - if not flow_previous_indicator or current_layout_mode() is not 'flow': - return - near_top = window.pageYOffset < 25 - near_bottom = abs(window.pageYOffset + window.innerHeight - document_height()) < 25 - csi = current_spine_item() - p = near_top and not csi.is_first - n = near_bottom and not csi.is_last - flow_previous_indicator.style.visibility = 'visible' if p else 'hidden' - flow_next_indicator.style.visibility = 'visible' if n else 'hidden' - flow_previous_indicator.style.opacity = '1' if p else '0' - flow_next_indicator.style.opacity = '1' if n else '0' - -def flow_change_mode(): - if flow_previous_indicator: - d = 'block' if current_layout_mode() is 'flow' else 'none' - flow_previous_indicator.style.display = flow_next_indicator.style.display = d +from read_book.globals import get_boss +from keycodes import get_key +from utils import document_height, document_width def flow_to_scroll_fraction(frac): window.scrollTo(0, document_height() * frac) + +def check_for_scroll_end(func): + return def (): + before = window.pageYOffset + func.apply(this, arguments) + if window.pageYOffset is before: + get_boss().send_message('next_spine_item', previous=window.pageYOffset < 5) + return False + return True + +@check_for_scroll_end +def scroll_by(y): + window.scrollBy(0, y) + +def flow_onwheel(evt): + dx = dy = 0 + if evt.deltaY: + if evt.deltaMode is evt.DOM_DELTA_PIXEL: + dy = evt.deltaY + elif evt.deltaMode is evt.DOM_DELTA_LINE: + dy = 15 * evt.deltaY + if evt.deltaMode is evt.DOM_DELTA_PAGE: + dy = (window.innerHeight - 30) * evt.deltaY + if evt.deltaX: + if evt.deltaMode is evt.DOM_DELTA_PIXEL: + dx = evt.deltaX + elif evt.deltaMode is evt.DOM_DELTA_LINE: + dx = 15 * evt.deltaX + else: + dx = (window.innerWidth - 30) * evt.deltaX + if dx: + window.scrollBy(dx, 0) + elif dy: + scroll_by(dy) + +smooth_y_data = {'last_event_at':0, 'up': False, 'timer':None, 'source':'wheel', 'pixels_per_ms': 0.2, 'scroll_interval':10, 'stop_scrolling_after':100} + +def do_y_scroll(): + if scroll_by((-1 if smooth_y_data.up else 1) * smooth_y_data.pixels_per_ms * smooth_y_data.scroll_interval): + if Date.now() - smooth_y_data.last_event_at < smooth_y_data.stop_scrolling_after: + smooth_y_data.timer = setTimeout(do_y_scroll, smooth_y_data.scroll_interval) + +def smooth_y_scroll(up): + clearTimeout(smooth_y_data.timer) + smooth_y_data.last_event_at = Date.now() + smooth_y_data.up = up + do_y_scroll() + +@check_for_scroll_end +def goto_start(): + window.scrollTo(window.pageXOffset, 0) + +@check_for_scroll_end +def goto_end(): + window.scrollTo(window.pageXOffset, document_height()) + +@check_for_scroll_end +def scroll_by_page(up): + h = window.innerHeight - 10 + window.scrollBy(0, -h if up else h) + +def flow_onkeydown(evt): + handled = False + key = get_key(evt) + if key is 'up' or key is 'down': + handled = True + if evt.ctrlKey: + goto_start() if key is 'up' else goto_end() + else: + smooth_y_scroll(key is 'up') + elif key is 'left' or key is 'right': + handled = True + if evt.ctrlKey: + window.scrollTo(0 if key is 'left' else document_width(), window.pageYOffset) + else: + window.scrollBy(-15 if key is 'left' else 15, 0) + elif key is 'home' or key is 'end': + handled = True + if evt.ctrlKey: + get_boss().send_message('goto_doc_boundary', start=key is 'home') + else: + if key is 'home': + window.scrollTo(window.pageXOffset, 0) + else: + window.scrollTo(window.pageXOffset, document_height()) + elif key is 'pageup' or key is 'pagedown' or key is 'space': + handled = True + scroll_by_page(key is 'pageup') + if handled: + evt.preventDefault() diff --git a/src/pyj/read_book/iframe.pyj b/src/pyj/read_book/iframe.pyj index bbf02783a4..c28a809bcb 100644 --- a/src/pyj/read_book/iframe.pyj +++ b/src/pyj/read_book/iframe.pyj @@ -3,11 +3,10 @@ from __python__ import bound_methods from aes import GCM -from elementmaker import E from gettext import install from read_book.globals import set_boss, set_current_spine_item, current_layout_mode, current_spine_item from read_book.resources import finalize_resources, unserialize_html -from read_book.flow_mode import setup_flow_indicators, update_flow_mode_scroll_indicators, flow_change_mode, flow_to_scroll_fraction +from read_book.flow_mode import flow_to_scroll_fraction, flow_onwheel, flow_onkeydown from utils import debounce class Boss: @@ -65,21 +64,30 @@ class Boss: unserialize_html(root_data, self.content_loaded) def content_loaded(self): - window.addEventListener('scroll', debounce(self.onscroll, 15)) - style = v'[]' - setup_flow_indicators(style) - document.head.appendChild(E.style(type='text/css', style.join('\n'))) - flow_change_mode() + document.documentElement.style.overflow = 'hidden' + window.addEventListener('scroll', debounce(self.update_cfi, 1000)) + window.addEventListener('resize', debounce(self.onresize, 500)) + window.addEventListener('wheel', self.onwheel) + window.addEventListener('keydown', self.onkeydown) csi = current_spine_item() - if csi.initial_scroll_fraction: + if csi.initial_scroll_fraction is not None: if current_layout_mode() is 'flow': flow_to_scroll_fraction(csi.initial_scroll_fraction) - def onscroll(self, evt): - if evt.view and evt.view is not window.top: - return - self.last_window_ypos = window.pageYOffset - update_flow_mode_scroll_indicators() + def update_cfi(self): + pass # TODO: Update CFI + + def onresize(self): + self.update_cfi() + + def onwheel(self, evt): + evt.preventDefault() + if current_layout_mode() is 'flow': + flow_onwheel(evt) + + def onkeydown(self, evt): + if current_layout_mode() is 'flow': + flow_onkeydown(evt) def send_message(self, action, **data): data.action = action diff --git a/src/pyj/read_book/view.pyj b/src/pyj/read_book/view.pyj index e2bd701bee..b5521ba861 100644 --- a/src/pyj/read_book/view.pyj +++ b/src/pyj/read_book/view.pyj @@ -51,6 +51,7 @@ class View: 'ready': self.on_iframe_ready, 'error': self.on_iframe_error, 'next_spine_item': self.on_next_spine_item, + 'goto_doc_boundary': self.goto_doc_boundary, } self.currently_showing = {'spine':0, 'cfi':None} @@ -117,22 +118,26 @@ class View: # TODO: Check for last open position of book self.show_name(book.manifest.spine[1]) - def show_name(self, name, initial_scroll_fraction=0): - self.currently_showing = {'name':name, 'cfi':None, 'initial_scroll_fraction':initial_scroll_fraction} + def show_name(self, name, initial_scroll_fraction=None, cfi=None): + self.currently_showing = {'name':name, 'cfi':cfi, 'initial_scroll_fraction':initial_scroll_fraction} load_resources(self.ui.db, self.book, name, self.loaded_resources, self.show_spine_item) + def goto_doc_boundary(self, data): + name = self.book.manifest.spine[0 if data.start else self.book.manifest.spine.length - 1] + self.show_name(name, initial_scroll_fraction=0 if data.start else 1) + def on_next_spine_item(self, data): spine = self.book.manifest.spine idx = spine.indexOf(self.currently_showing.name) if data.previous: if idx is 0: return - idx = max(idx - 1, 0) + idx = min(spine.length - 1, max(idx - 1, 0)) self.show_name(spine[idx], initial_scroll_fraction=1) else: if idx is spine.length - 1: return - idx = min(spine.length - 1, idx + 1) + idx = max(0, min(spine.length - 1, idx + 1)) self.show_name(spine[idx]) def show_spine_item(self, resource_data): diff --git a/src/pyj/utils.pyj b/src/pyj/utils.pyj index 280c743977..9f54876af7 100644 --- a/src/pyj/utils.pyj +++ b/src/pyj/utils.pyj @@ -80,6 +80,14 @@ def human_readable(size, sep=' '): size = size[:-2] return size + sep + suffix +def document_height(): + html = document.documentElement + return max(document.body.scrollHeight, document.body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight) + +def document_width(): + html = document.documentElement + return max(document.body.scrollWidth, document.body.offsetWidth, html.clientWidth, html.scrollWidth, html.offsetWidth) + if __name__ is '__main__': print(fmt_sidx(10), fmt_sidx(1.2)) print(list(map(human_readable, [1, 1024.0, 1025, 1024*1024*2.3])))