Browser viewer: Allow long tapping a word to look it up in a dictionary or search the internet for it. Fixes #1738995 [[Browser Viewer] not possible to select text on Android](https://bugs.launchpad.net/calibre/+bug/1738995)

This commit is contained in:
Kovid Goyal 2018-02-21 17:16:07 +05:30
parent 57a027c31e
commit 2e8692bfcb
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
7 changed files with 126 additions and 10 deletions

View File

@ -2,6 +2,8 @@
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net> # License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
from __python__ import bound_methods, hash_literals from __python__ import bound_methods, hash_literals
from select import word_at_point
from dom import set_css from dom import set_css
from keycodes import get_key from keycodes import get_key
from read_book.globals import get_boss from read_book.globals import get_boss
@ -164,6 +166,14 @@ def handle_gesture(gesture):
scroll_by_page(True) scroll_by_page(True)
elif gesture.type is 'next-page': elif gesture.type is 'next-page':
scroll_by_page(False) scroll_by_page(False)
elif gesture.type is 'long-tap':
r = word_at_point(gesture.viewport_x, gesture.viewport_y)
if r:
s = document.getSelection()
s.removeAllRanges()
s.addRange(r)
get_boss().send_message('lookup_word', word=str(r))
anchor_funcs = { anchor_funcs = {
'pos_for_elem': def pos_for_elem(elem): 'pos_for_elem': def pos_for_elem(elem):

View File

@ -16,6 +16,7 @@ from read_book.goto import create_goto_panel
from read_book.prefs.font_size import create_font_size_panel from read_book.prefs.font_size import create_font_size_panel
from read_book.prefs.main import create_prefs_panel from read_book.prefs.main import create_prefs_panel
from read_book.toc import create_toc_panel from read_book.toc import create_toc_panel
from read_book.word_actions import create_word_actions_panel
from session import get_device_uuid from session import get_device_uuid
from widgets import create_button, create_spinner from widgets import create_button, create_spinner
@ -309,6 +310,26 @@ class TOCOverlay: # {{{
self.overlay.view.goto_named_destination(dest, frag) self.overlay.view.goto_named_destination(dest, frag)
# }}} # }}}
class WordActionsOverlay: # {{{
def __init__(self, word, overlay):
self.word = word
self.overlay = overlay
def on_container_click(self, evt):
pass # Dont allow panel to be closed by a click
def show(self, container):
container.style.backgroundColor = get_color('window-background')
container.appendChild(E.div(
style='padding: 1ex 1em; border-bottom: solid 1px currentColor; display:flex; justify-content: space-between',
E.h2(_('Lookup: {}').format(self.word)),
E.div(svgicon('close'), style='cursor:pointer', onclick=def(event):event.preventDefault(), event.stopPropagation(), self.overlay.hide_current_panel(event);, class_='simple-link'),
))
container.appendChild(E.div())
create_word_actions_panel(container, self.word, self.overlay.hide)
# }}}
class PrefsOverlay: # {{{ class PrefsOverlay: # {{{
def __init__(self, overlay): def __init__(self, overlay):
@ -330,13 +351,15 @@ class PrefsOverlay: # {{{
# }}} # }}}
class FontSizeOverlay: class FontSizeOverlay: # {{{
def __init__(self, overlay): def __init__(self, overlay):
self.overlay = overlay self.overlay = overlay
def show(self, container): def show(self, container):
create_font_size_panel(container, self.overlay.hide_current_panel) create_font_size_panel(container, self.overlay.hide_current_panel)
# }}}
class Overlay: class Overlay:
@ -439,3 +462,8 @@ class Overlay:
self.hide_current_panel() self.hide_current_panel()
self.panels.push(FontSizeOverlay(self)) self.panels.push(FontSizeOverlay(self))
self.show_current_panel() self.show_current_panel()
def show_word_actions(self, word):
self.hide_current_panel()
self.panels.push(WordActionsOverlay(word, self))
self.show_current_panel()

View File

@ -4,6 +4,7 @@ from __python__ import hash_literals
import traceback import traceback
from elementmaker import E from elementmaker import E
from select import word_at_point
from dom import set_css from dom import set_css
from keycodes import get_key from keycodes import get_key
@ -507,6 +508,13 @@ def handle_gesture(gesture):
scroll_by_page(True, False) scroll_by_page(True, False)
elif gesture.type is 'next-page': elif gesture.type is 'next-page':
scroll_by_page(False, False) scroll_by_page(False, False)
elif gesture.type is 'long-tap':
r = word_at_point(gesture.viewport_x, gesture.viewport_y)
if r:
s = document.getSelection()
s.removeAllRanges()
s.addRange(r)
get_boss().send_message('lookup_word', word=str(r))
anchor_funcs = { anchor_funcs = {

View File

@ -10,6 +10,7 @@ HOLD_THRESHOLD = 750 # milliseconds
TAP_THRESHOLD = 7 # pixels TAP_THRESHOLD = 7 # pixels
TAP_LINK_THRESHOLD = 5 # pixels TAP_LINK_THRESHOLD = 5 # pixels
PINCH_THRESHOLD = 10 # pixels PINCH_THRESHOLD = 10 # pixels
LONG_TAP_THRESHOLD = 500 # milliseconds
gesture_id = 0 gesture_id = 0
@ -39,7 +40,7 @@ def max_displacement(points):
def interpret_single_gesture(touch, gesture_id): def interpret_single_gesture(touch, gesture_id):
max_x_displacement = max_displacement(touch.viewport_x) max_x_displacement = max_displacement(touch.viewport_x)
max_y_displacement = max_displacement(touch.viewport_y) max_y_displacement = max_displacement(touch.viewport_y)
ans = {'active':touch.active, 'is_held':touch.is_held, 'id':gesture_id} ans = {'active':touch.active, 'is_held':touch.is_held, 'id':gesture_id, 'start_time': touch.ctime}
if max(max_x_displacement, max_y_displacement) < TAP_THRESHOLD: if max(max_x_displacement, max_y_displacement) < TAP_THRESHOLD:
ans.type = 'tap' ans.type = 'tap'
ans.viewport_x = touch.viewport_x[0] ans.viewport_x = touch.viewport_x[0]
@ -234,15 +235,10 @@ class BookTouchHandler(TouchHandler):
gesture.from_side_margin = self.for_side_margin gesture.from_side_margin = self.for_side_margin
if gesture.type is 'tap': if gesture.type is 'tap':
if gesture.is_held: if gesture.is_held:
if not self.for_side_margin and not self.handled_tap_hold: if not self.for_side_margin and not self.handled_tap_hold and window.performance.now() - gesture.start_time >= LONG_TAP_THRESHOLD:
self.handled_tap_hold = True self.handled_tap_hold = True
fake_click = new MouseEvent('click', { gesture.type = 'long-tap'
'clientX': gesture.viewport_x, 'clientY':gesture.viewport_y, 'buttons': 1, get_boss().handle_gesture(gesture)
'view':window.self, 'bubbles': True, 'cancelable': True,
})
elem = document.elementFromPoint(fake_click.clientX, fake_click.clientY)
if elem:
elem.dispatchEvent(fake_click)
return return
if not gesture.active: if not gesture.active:
if self.for_side_margin or not tap_on_link(gesture): if self.for_side_margin or not tap_on_link(gesture):

View File

@ -150,6 +150,7 @@ class View:
'error': self.on_iframe_error, 'error': self.on_iframe_error,
'next_spine_item': self.on_next_spine_item, 'next_spine_item': self.on_next_spine_item,
'next_section': self.on_next_section, 'next_section': self.on_next_section,
'lookup_word': self.on_lookup_word,
'goto_doc_boundary': def(data): self.goto_doc_boundary(data.start);, 'goto_doc_boundary': def(data): self.goto_doc_boundary(data.start);,
'scroll_to_anchor': self.on_scroll_to_anchor, 'scroll_to_anchor': self.on_scroll_to_anchor,
'update_cfi': self.on_update_cfi, 'update_cfi': self.on_update_cfi,
@ -174,6 +175,9 @@ class View:
def iframe(self): def iframe(self):
return self.iframe_wrapper.iframe return self.iframe_wrapper.iframe
def on_lookup_word(self, data):
self.overlay.show_word_actions(data.word)
def left_margin_clicked(self, event): def left_margin_clicked(self, event):
if event.button is 0: if event.button is 0:
event.preventDefault(), event.stopPropagation() event.preventDefault(), event.stopPropagation()

View File

@ -0,0 +1,26 @@
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
from __python__ import bound_methods, hash_literals
from gettext import gettext as _
from book_list.item_list import create_item, create_item_list
def lookup(close_panel_func, url):
close_panel_func()
window.open(url, '_blank')
def lookup_items(word, close_panel_func):
eword = encodeURIComponent(word)
items = [
create_item(_('Lookup in Google dictionary'), lookup.bind(word, close_panel_func, f'https://google.com/search?q=define:{eword}')),
create_item(_('Lookup in Wordnik'), lookup.bind(word, close_panel_func, f'https://www.wordnik.com/words/{eword}')),
create_item(_('Search the internet'), lookup.bind(word, close_panel_func, f'https://google.com/search?q={eword}')),
]
return items
def create_word_actions_panel(container, word, close_panel_func):
list_container = container.lastChild
create_item_list(list_container, lookup_items(word, close_panel_func))

44
src/pyj/select.pyj Normal file
View File

@ -0,0 +1,44 @@
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
from __python__ import bound_methods, hash_literals
def range_from_point(x, y):
r = None
if document.caretPositionFromPoint:
p = document.caretPositionFromPoint(x, y)
if p:
r = document.createRange()
r.setStart(p.offsetNode, p.offset)
r.collapse(True)
elif document.caretRangeFromPoint:
r = document.caretRangeFromPoint(x, y)
return r
def word_boundary_regex():
ans = word_boundary_regex.ans
if ans is undefined:
ans = word_boundary_regex.ans = /[\s!-#%-\x2A,-/:;\x3F@\x5B-\x5D_\x7B}\u00A1\u00A7\u00AB\u00B6\u00B7\u00BB\u00BF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E3B\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]/
return ans
def expand_offset_to_word(string, offset):
start = offset
pat = word_boundary_regex()
while start >= 1 and not pat.test(string.charAt(start - 1)):
start -= 1
end, sz = offset, string.length
while end < sz and not pat.test(string.charAt(end)):
end += 1
return {'word': string[start:end], 'start': start, 'end': end}
def word_at_point(x, y):
r = range_from_point(x, y)
if r and r.startContainer.nodeType is 3:
word_info = expand_offset_to_word(r.startContainer.data, r.startOffset)
if word_info.word:
r.setStart(r.startContainer, word_info.start)
r.setEnd(r.startContainer, word_info.end)
return r