calibre/src/pyj/read_book/iframe.pyj
Kovid Goyal a1d2209fc7
E-book viewer: Fix copy to clipboard not ignoring text from elements that are marked as non user selectable. Fixes #1991504 [Copy text does not respect user-select: none](https://bugs.launchpad.net/calibre/+bug/1991504)
Apparently the bug in webengine where it does not copy HTML to the
clipbaord is no more, so we can go back to relying on the native copy
which does exclude such text automatically.
2022-10-09 15:25:42 +05:30

1018 lines
44 KiB
Plaintext

# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
from __python__ import bound_methods, hash_literals
import traceback
from fs_images import fix_fullscreen_svg_images
from iframe_comm import IframeClient
from range_utils import (
all_annots_in_selection, highlight_associated_with_selection, last_span_for_crw,
reset_highlight_counter, select_crw, unwrap_all_crw, unwrap_crw, wrap_text_in_range
)
from read_book.cfi import cfi_for_selection, range_from_cfi
from read_book.extract import get_elements
from read_book.find import (
reset_find_caches, select_search_result, select_tts_mark, tts_data
)
from read_book.flow_mode import (
anchor_funcs as flow_anchor_funcs, auto_scroll_action as flow_auto_scroll_action,
cancel_drag_scroll as cancel_drag_scroll_flow,
ensure_selection_boundary_visible as ensure_selection_boundary_visible_flow,
ensure_selection_visible, flow_onwheel, flow_to_scroll_fraction,
handle_gesture as flow_handle_gesture, handle_shortcut as flow_handle_shortcut,
jump_to_cfi as flow_jump_to_cfi, layout as flow_layout,
scroll_by_page as flow_scroll_by_page,
scroll_to_extend_annotation as flow_annotation_scroll,
start_drag_scroll as start_drag_scroll_flow
)
from read_book.footnotes import is_footnote_link
from read_book.globals import (
annot_id_uuid_map, clear_annot_id_uuid_map, current_book, current_layout_mode,
current_spine_item, runtime, set_boss, set_current_spine_item, set_layout_mode,
set_toc_anchor_map
)
from read_book.highlights import highlight_style_as_css
from read_book.hints import apply_prefix_to_hints, hint_visible_links, unhint_links
from read_book.mathjax import apply_mathjax
from read_book.paged_mode import (
anchor_funcs as paged_anchor_funcs, auto_scroll_action as paged_auto_scroll_action,
calc_columns_per_screen, cancel_drag_scroll as cancel_drag_scroll_paged,
current_cfi,
ensure_selection_boundary_visible as ensure_selection_boundary_visible_paged,
get_columns_per_screen_data, handle_gesture as paged_handle_gesture,
handle_shortcut as paged_handle_shortcut, jump_to_cfi as paged_jump_to_cfi,
layout as paged_layout, onwheel as paged_onwheel, page_counts,
prepare_for_resize as paged_prepare_for_resize, progress_frac,
reset_paged_mode_globals, resize_done as paged_resize_done,
scroll_by_page as paged_scroll_by_page, scroll_to_elem,
scroll_to_extend_annotation as paged_annotation_scroll,
scroll_to_fraction as paged_scroll_to_fraction, snap_to_selection,
start_drag_scroll as start_drag_scroll_paged, will_columns_per_screen_change
)
from read_book.referencing import elem_for_ref, end_reference_mode, start_reference_mode
from read_book.resources import finalize_resources, unserialize_html
from read_book.settings import (
apply_colors, apply_font_size, apply_settings, apply_stylesheet, opts,
set_color_scheme_class, set_selection_style, update_settings
)
from read_book.shortcuts import (
create_shortcut_map, keyevent_as_shortcut, shortcut_for_key_event
)
from read_book.toc import find_anchor_before_range, update_visible_toc_anchors
from read_book.touch import (
create_handlers as create_touch_handlers, reset_handlers as reset_touch_handlers
)
from read_book.viewport import scroll_viewport
from select import (
first_visible_word, is_start_closer_to_point, move_end_of_selection,
selection_extents, word_at_point
)
from utils import debounce, is_ios
FORCE_FLOW_MODE = False
CALIBRE_VERSION = '__CALIBRE_VERSION__'
ONSCROLL_DEBOUNCE_TIME = 1000
ERS_SUPPORTED_FEATURES = {'dom-manipulation', 'layout-changes', 'touch-events', 'mouse-events', 'keyboard-events', 'spine-scripting'}
def layout_style():
return 'scrolling' if current_layout_mode() is 'flow' else 'paginated'
drag_mouse_position = {'x': None, 'y': None}
def cancel_drag_scroll():
cancel_drag_scroll_flow()
cancel_drag_scroll_paged()
class EPUBReadingSystem:
@property
def name(self):
return 'calibre'
@property
def version(self):
return CALIBRE_VERSION
@property
def layoutStyle(self):
return layout_style()
def hasFeature(self, feature, version):
return feature in ERS_SUPPORTED_FEATURES
def __repr__(self):
return f'{{name:{self.name}, version:{self.version}, layoutStyle:{self.layoutStyle}}}'
def __str__(self):
return self.__repr__()
class FullBookSearch:
def __init__(self):
start_spine_index = current_spine_item()?.index
if not start_spine_index?:
start_spine_index = -1
self.start_spine_index = start_spine_index
self.progress_frac_at_start = progress_frac()
self.first_result_shown = False
class IframeBoss:
def __init__(self):
window.navigator.epubReadingSystem = EPUBReadingSystem()
self.last_cfi = None
self.last_search_at = -1001
self.reference_mode_enabled = False
self.replace_history_on_next_cfi_update = True
self.blob_url_map = {}
self.content_ready = False
self.last_window_width = self.last_window_height = -1
self.forward_keypresses = False
self.full_book_search_in_progress = None
set_boss(self)
handlers = {
'change_color_scheme': self.change_color_scheme,
'change_font_size': self.change_font_size,
'viewer_font_size_changed': self.viewer_font_size_changed,
'change_number_of_columns': self.change_number_of_columns,
'number_of_columns_changed': self.number_of_columns_changed,
'change_scroll_speed': self.change_scroll_speed,
'display': self.display,
'gesture_from_margin': self.gesture_from_margin,
'get_current_cfi': self.get_current_cfi,
'initialize':self.initialize,
'modify_selection': self.modify_selection,
'next_screen': self.on_next_screen,
'scroll_to_anchor': self.on_scroll_to_anchor,
'scroll_to_frac': self.on_scroll_to_frac,
'scroll_to_ref': self.on_scroll_to_ref,
'fake_popup_activation': self.on_fake_popup_activation,
'set_reference_mode': self.set_reference_mode,
'toggle_autoscroll': self.toggle_autoscroll,
'fake_wheel_event': self.fake_wheel_event,
'window_size': self.received_window_size,
'overlay_visibility_changed': self.on_overlay_visibility_changed,
'show_search_result': self.show_search_result,
'handle_navigation_shortcut': self.on_handle_navigation_shortcut,
'annotations': self.annotations_msg_received,
'tts': self.tts_msg_received,
'hints': self.hints_msg_received,
'copy_selection': self.copy_selection,
'replace_highlights': self.replace_highlights,
'clear_selection': def(): window.getSelection().removeAllRanges();,
}
self.comm = IframeClient(handlers, 'main-iframe')
self.last_window_ypos = 0
self.length_before = None
def on_overlay_visibility_changed(self, data):
cancel_drag_scroll()
if data.visible:
self.forward_keypresses = True
if self.auto_scroll_action:
self.auto_scroll_active_before_overlay = self.auto_scroll_action('is_active')
self.auto_scroll_action('stop')
else:
self.forward_keypresses = False
if self.auto_scroll_active_before_overlay:
self.auto_scroll_active_before_overlay = undefined
self.auto_scroll_action('start')
def modify_selection(self, data):
sel = window.getSelection()
use_end = False
if data.granularity is 'all':
r = document.createRange()
r.selectNode(document.body)
sel.removeAllRanges()
sel.addRange(r)
else:
use_end = data.direction is 'forward' or data.direction is 'right'
try:
sel.modify('extend', data.direction, data.granularity)
except:
if data.granularity is 'paragraph':
sel.modify('extend', data.direction, 'line')
self.ensure_selection_boundary_visible(use_end)
def initialize(self, data):
scroll_viewport.update_window_size(data.width, data.height)
window.addEventListener('error', self.onerror)
window.addEventListener('scroll', debounce(self.onscroll, ONSCROLL_DEBOUNCE_TIME))
window.addEventListener('scroll', self.no_latency_onscroll)
window.addEventListener('resize', debounce(self.onresize, 500))
window.addEventListener('wheel', self.onwheel, {'passive': False})
window.addEventListener('keydown', self.onkeydown, {'passive': False})
window.addEventListener('mousemove', self.onmousemove, {'passive': True})
window.addEventListener('mouseup', self.onmouseup, {'passive': True})
window.addEventListener('dblclick', self.ondoubleclick, {'passive': True})
document.documentElement.addEventListener('contextmenu', self.oncontextmenu, {'passive': False})
document.addEventListener('selectionchange', self.onselectionchange)
self.color_scheme = data.color_scheme
create_touch_handlers()
def onerror(self, evt):
msg = evt.message
script_url = evt.filename
line_number = evt.lineno
column_number = evt.colno
error_object = evt.error
evt.stopPropagation(), evt.preventDefault()
if error_object is None:
# This happens for cross-domain errors (probably javascript injected
# into the browser via extensions/ userscripts and the like). It also
# happens all the time when using Chrome on Safari, so ignore this
# type of error
console.log(f'Unhandled error from external javascript, ignoring: {msg} {script_url} {line_number}')
return
is_internal_error = script_url in ('about:srcdoc', 'userscript:viewer.js')
if is_internal_error: # dont report errors from scripts in the book itself
console.log(f'{script_url}: {error_object}')
try:
fname = script_url.rpartition('/')[-1] or script_url
msg = msg + '<br><span style="font-size:smaller">' + 'Error at {}:{}:{}'.format(fname, line_number, column_number or '') + '</span>'
details = traceback.format_exception(error_object).join('') if error_object else ''
if details:
console.log(details)
self.send_message('error', errkey='unhandled-error', details=details, msg=msg)
except:
console.log('There was an error in the iframe unhandled exception handler')
else:
(console.error or console.log)('There was an error in the JavaScript from within the book')
def display(self, data):
cancel_drag_scroll()
drag_mouse_position.x = drag_mouse_position.y = None
self.length_before = None
self.content_ready = False
clear_annot_id_uuid_map()
reset_highlight_counter()
set_toc_anchor_map()
self.replace_history_on_next_cfi_update = True
self.book = current_book.book = data.book
self.link_attr = 'data-' + self.book.manifest.link_uid
self.reference_mode_enabled = data.reference_mode_enabled
self.is_titlepage = data.is_titlepage
spine = self.book.manifest.spine
index = spine.indexOf(data.name)
reset_paged_mode_globals()
set_layout_mode('flow' if FORCE_FLOW_MODE else data.settings.read_mode)
if current_layout_mode() is 'flow':
self.do_layout = flow_layout
self.handle_wheel = flow_onwheel
self.handle_navigation_shortcut = flow_handle_shortcut
self._handle_gesture = flow_handle_gesture
self.to_scroll_fraction = flow_to_scroll_fraction
self.jump_to_cfi = flow_jump_to_cfi
self.anchor_funcs = flow_anchor_funcs
self.auto_scroll_action = flow_auto_scroll_action
self.scroll_to_extend_annotation = flow_annotation_scroll
self.ensure_selection_visible = ensure_selection_visible
self.ensure_selection_boundary_visible = ensure_selection_boundary_visible_flow
self.start_drag_scroll = start_drag_scroll_flow
paged_auto_scroll_action('stop')
else:
self.do_layout = paged_layout
self.handle_wheel = paged_onwheel
self.handle_navigation_shortcut = paged_handle_shortcut
self.to_scroll_fraction = paged_scroll_to_fraction
self.jump_to_cfi = paged_jump_to_cfi
self._handle_gesture = paged_handle_gesture
self.anchor_funcs = paged_anchor_funcs
self.auto_scroll_action = paged_auto_scroll_action
self.scroll_to_extend_annotation = paged_annotation_scroll
self.ensure_selection_visible = snap_to_selection
self.ensure_selection_boundary_visible = ensure_selection_boundary_visible_paged
self.start_drag_scroll = start_drag_scroll_paged
flow_auto_scroll_action('stop')
update_settings(data.settings)
self.keyboard_shortcut_map = create_shortcut_map(data.settings.keyboard_shortcuts)
set_current_spine_item({
'name':data.name,
'is_first':index is 0,
'is_last':index is spine.length - 1,
'index': index,
'initial_position':data.initial_position
})
self.last_cfi = None
for name in self.blob_url_map:
window.URL.revokeObjectURL(self.blob_url_map[name])
document.body.style.removeProperty('font-family')
root_data, self.mathjax, self.blob_url_map = finalize_resources(self.book, data.name, data.resource_data)
self.highlights_to_apply = data.highlights
unserialize_html(root_data, self.content_loaded, None, data.name)
def on_scroll_to_frac(self, data):
self.to_scroll_fraction(data.frac, False)
def toggle_autoscroll(self):
self.auto_scroll_action('toggle')
def handle_gesture(self, gesture):
if gesture.type is 'show-chrome':
self.send_message('show_chrome')
elif gesture.type is 'pinch':
self.send_message('bump_font_size', increase=gesture.direction is 'out')
elif gesture.type is 'long-tap':
self.handle_long_tap(gesture)
else:
self._handle_gesture(gesture)
def handle_long_tap(self, gesture):
elements = get_elements(gesture.viewport_x, gesture.viewport_y)
if elements.img:
self.send_message('view_image', calibre_src=elements.img)
return
if elements.highlight:
select_crw(elements.crw)
return
r = word_at_point(gesture.viewport_x, gesture.viewport_y)
if r:
s = document.getSelection()
s.removeAllRanges()
s.addRange(r)
def gesture_from_margin(self, data):
self.handle_gesture(data.gesture)
def fake_wheel_event(self, data):
# these are wheel events from margin or selection mode
self.onwheel(data.evt)
def report_human_scroll(self, scrolled_by_frac):
self.send_message('human_scroll', scrolled_by_frac=scrolled_by_frac or None)
def on_scroll_to_anchor(self, data):
frag = data.frag
if frag:
self.scroll_to_anchor(frag)
else:
self.to_scroll_fraction(0.0, False)
def on_next_screen(self, data):
backwards = data.backwards
if current_layout_mode() is 'flow':
flow_scroll_by_page(-1 if backwards else 1, data.flip_if_rtl_page_progression)
else:
paged_scroll_by_page(backwards, data.all_pages_on_screen, data.flip_if_rtl_page_progression)
def change_font_size(self, data):
if data.base_font_size? and data.base_font_size != opts.base_font_size:
opts.base_font_size = data.base_font_size
apply_font_size()
if not runtime.is_standalone_viewer:
# in the standalone viewer this is a separate event as
# apply_font_size() is a no-op
self.relayout_on_font_size_change()
def viewer_font_size_changed(self, data):
self.relayout_on_font_size_change()
def change_number_of_columns(self, data):
if current_layout_mode() is 'flow':
self.send_message('error', errkey='changing-columns-in-flow-mode')
return
cdata = get_columns_per_screen_data()
delta = int(data.delta)
if delta is 0:
new_val = 0
else:
new_val = max(1, cdata.cps + delta)
opts.columns_per_screen[cdata.which] = new_val
self.relayout_on_font_size_change()
self.send_message('columns_per_screen_changed', which=cdata.which, cps=new_val)
def relayout_on_font_size_change(self):
if current_layout_mode() is not 'flow' and will_columns_per_screen_change():
self.do_layout(self.is_titlepage)
if self.last_cfi:
cfi = self.last_cfi[len('epubcfi(/'):-1].partition('/')[2]
if cfi:
self.jump_to_cfi('/' + cfi)
self.update_cfi()
self.update_toc_position()
def number_of_columns_changed(self, data):
opts.columns_per_screen = data.columns_per_screen
self.relayout_on_font_size_change()
def change_scroll_speed(self, data):
if data.lines_per_sec_auto?:
opts.lines_per_sec_auto = data.lines_per_sec_auto
def change_stylesheet(self, data):
opts.user_stylesheet = data.sheet or ''
apply_stylesheet()
def change_color_scheme(self, data):
if data.color_scheme and data.color_scheme.foreground and data.color_scheme.background:
opts.color_scheme = data.color_scheme
apply_colors()
set_color_scheme_class()
def content_loaded(self):
document.documentElement.style.overflow = 'hidden'
if self.is_titlepage and not opts.cover_preserve_aspect_ratio:
document.body.classList.add('cover-fill')
document.body.classList.add(f'calibre-viewer-{layout_style()}')
set_color_scheme_class()
if self.reference_mode_enabled:
start_reference_mode()
self.last_window_width, self.last_window_height = scroll_viewport.width(), scroll_viewport.height()
if self.highlights_to_apply:
self.apply_highlights_on_load(self.highlights_to_apply)
self.highlights_to_apply = None
apply_settings()
fix_fullscreen_svg_images()
self.do_layout(self.is_titlepage)
if self.mathjax:
return apply_mathjax(self.mathjax, self.book.manifest.link_uid, self.content_loaded_stage2)
# window.setTimeout(self.content_loaded_stage2, 1000)
self.content_loaded_stage2()
def content_loaded_stage2(self):
reset_find_caches()
self.connect_links()
if runtime.is_standalone_viewer:
self.listen_for_image_double_clicks()
self.content_ready = True
if document.head?.firstChild:
# this is the loading styles used to suppress scrollbars during load
# added in unserialize_html
document.head.removeChild(document.head.firstChild)
csi = current_spine_item()
if csi.initial_position:
ipos = csi.initial_position
self.replace_history_on_next_cfi_update = ipos.replace_history or False
if ipos.type is 'frac':
self.to_scroll_fraction(ipos.frac, True)
elif ipos.type is 'anchor':
self.scroll_to_anchor(ipos.anchor)
elif ipos.type is 'ref':
self.scroll_to_ref(ipos.refnum)
elif ipos.type is 'cfi':
self.jump_to_cfi(ipos.cfi)
elif ipos.type is 'search_result':
self.show_search_result(ipos, True)
elif ipos.type is 'edit_annotation':
window.setTimeout(def():
self.annotations_msg_received({'type': 'edit-highlight', 'uuid': ipos.uuid})
, 5)
spine = self.book.manifest.spine
files = self.book.manifest.files
spine_index = csi.index
self.length_before = 0
if spine_index > -1:
for i in range(spine_index):
si = spine[i]
if si:
self.length_before += files[si]?.length or 0
self.send_message(
'content_loaded', progress_frac=self.calculate_progress_frac(),
file_progress_frac=progress_frac(), page_counts=page_counts()
)
self.last_cfi = None
self.auto_scroll_action('resume')
reset_touch_handlers() # Needed to mitigate issue https://bugs.chromium.org/p/chromium/issues/detail?id=464579
window.setTimeout(self.update_cfi, ONSCROLL_DEBOUNCE_TIME)
window.setTimeout(self.update_toc_position, 0)
load_event = document.createEvent('Event')
load_event.initEvent('load', False, False)
window.dispatchEvent(load_event)
def calculate_progress_frac(self):
current_name = current_spine_item().name
files = self.book.manifest.files
file_length = files[current_name]?.length or 0
if self.length_before is None:
return 0
frac = progress_frac()
ans = (self.length_before + (file_length * frac)) / self.book.manifest.spine_length
return ans
def get_current_cfi(self, data):
cfi = current_cfi()
csi = current_spine_item()
def epubcfi(cfi):
return 'epubcfi(/{}{})'.format(2*(index+1), cfi) if cfi else None
if cfi and csi:
index = csi.index
if index > -1:
selection = window.getSelection()
selcfi = seltext = None
if selection and not selection.isCollapsed:
seltext = selection.toString()
selcfi = cfi_for_selection()
selcfi.start = epubcfi(selcfi.start)
selcfi.end = epubcfi(selcfi.end)
cfi = epubcfi(cfi)
self.send_message(
'report_cfi', cfi=cfi, progress_frac=self.calculate_progress_frac(),
file_progress_frac=progress_frac(), request_id=data.request_id,
selected_text=seltext, selection_bounds=selcfi, page_counts=page_counts())
return
self.send_message(
'report_cfi', cfi=None, progress_frac=0, file_progress_frac=0, page_counts=page_counts(), request_id=data.request_id)
def update_cfi(self, force_update):
cfi = current_cfi()
if cfi:
index = current_spine_item().index
if index > -1:
cfi = 'epubcfi(/{}{})'.format(2*(index+1), cfi)
pf = self.calculate_progress_frac()
fpf = progress_frac()
if cfi is not self.last_cfi or force_update:
self.last_cfi = cfi
self.send_message(
'update_cfi', cfi=cfi, replace_history=self.replace_history_on_next_cfi_update,
progress_frac=pf, file_progress_frac=fpf, page_counts=page_counts())
self.replace_history_on_next_cfi_update = True
else:
self.send_message(
'update_progress_frac', progress_frac=pf, file_progress_frac=fpf, page_counts=page_counts())
def update_toc_position(self):
visible_anchors = update_visible_toc_anchors(self.book.manifest.toc_anchor_map, self.anchor_funcs)
self.send_message('update_toc_position', visible_anchors=visible_anchors)
def onscroll(self):
if self.content_ready:
self.update_cfi()
self.update_toc_position()
def no_latency_onscroll(self):
pf = self.calculate_progress_frac()
fpf = progress_frac()
self.send_message(
'update_progress_frac', progress_frac=pf, file_progress_frac=fpf, page_counts=page_counts())
sel = window.getSelection()
if sel and not sel.isCollapsed:
self.send_message('update_selection_position', selection_extents=selection_extents(current_layout_mode() is 'flow', True))
def onresize(self):
self.send_message('request_size')
if self.content_ready:
if is_ios:
# On iOS window.innerWidth/Height are wrong inside the iframe,
# so we wait for the reply from request_size
return
self.onresize_stage2()
def onselectionchange(self):
if not self.content_ready:
return
sel = window.getSelection()
text = ''
annot_id = None
collapsed = not sel or sel.isCollapsed
start_is_anchor = True
if not collapsed:
text = sel.toString()
annot_id = highlight_associated_with_selection(sel, annot_id_uuid_map)
r = sel.getRangeAt(0)
start_is_anchor = r.startContainer is sel.anchorNode and r.startOffset is sel.anchorOffset
now = window.performance.now()
by_search = now - self.last_search_at < 1000
self.send_message(
'selectionchange', text=text, empty=v'!!collapsed', annot_id=annot_id,
drag_mouse_position=drag_mouse_position, selection_change_caused_by_search=by_search,
selection_extents=selection_extents(current_layout_mode() is 'flow'),
rtl=scroll_viewport.rtl, vertical=scroll_viewport.vertical_writing_mode,
start_is_anchor=start_is_anchor
)
def onresize_stage2(self):
if scroll_viewport.width() is self.last_window_width and scroll_viewport.height() is self.last_window_height:
# Safari at least, generates lots of spurious resize events
return
if current_layout_mode() is not 'flow':
paged_prepare_for_resize(self.last_window_width, self.last_window_height)
self.do_layout(self.is_titlepage)
self.last_window_width, self.last_window_height = scroll_viewport.width(), scroll_viewport.height()
if self.last_cfi:
cfi = self.last_cfi[len('epubcfi(/'):-1].partition('/')[2]
if cfi:
self.jump_to_cfi('/' + cfi)
if current_layout_mode() is not 'flow':
paged_resize_done()
self.update_cfi()
self.update_toc_position()
sel = window.getSelection()
if sel and not sel.isCollapsed:
# update_selection_position has probably already been called by
# no_latency_onscroll but make sure
self.send_message('update_selection_position', selection_extents=selection_extents(current_layout_mode() is 'flow'))
def received_window_size(self, data):
scroll_viewport.update_window_size(data.width, data.height)
if self.content_ready:
self.onresize_stage2()
def onwheel(self, evt):
if self.content_ready:
if evt.preventDefault:
evt.preventDefault()
if evt.deltaY and evt.ctrlKey and not evt.shiftKey and not evt.altKey and not evt.metaKey:
self.send_message('handle_shortcut', name='increase_font_size' if evt.deltaY < 0 else 'decrease_font_size')
else:
self.handle_wheel(evt)
def onmousemove(self, evt):
if evt.buttons is not 1:
return
drag_mouse_position.x = evt.clientX
drag_mouse_position.y = evt.clientY
if 0 <= evt.clientY <= window.innerHeight:
cancel_drag_scroll()
return
sel = window.getSelection()
if not sel:
cancel_drag_scroll()
return
delta = evt.clientY if evt.clientY < 0 else (evt.clientY - window.innerHeight)
self.start_drag_scroll(delta)
def onmouseup(self, evt):
cancel_drag_scroll()
drag_mouse_position.x = drag_mouse_position.y = None
# ensure selection bar is updated
self.onselectionchange()
if evt.button is 3 or evt.button is 4:
self.send_message('handle_shortcut', name='back' if evt.button is 3 else 'forward')
def ondoubleclick(self, evt):
self.send_message('annotations', type='double-click')
def onkeydown(self, evt):
if current_layout_mode() is not 'flow' and evt.key is 'Tab':
# Prevent the TAB key from shifting focus as it causes partial scrolling
evt.preventDefault()
if self.forward_keypresses:
self.send_message('handle_keypress', evt=keyevent_as_shortcut(evt))
evt.preventDefault(), evt.stopPropagation()
return
if self.content_ready:
sc_name = shortcut_for_key_event(evt, self.keyboard_shortcut_map)
if sc_name:
evt.preventDefault()
if not self.handle_navigation_shortcut(sc_name, evt):
self.send_message('handle_shortcut', name=sc_name)
def on_handle_navigation_shortcut(self, data):
self.handle_navigation_shortcut(data.name, data.key or {
'key': '', 'altKey': False, 'ctrlKey': False, 'shiftKey': False, 'metaKey': False})
def oncontextmenu(self, evt):
if self.content_ready:
evt.preventDefault()
self.send_message('show_chrome', elements=get_elements(evt.clientX, evt.clientY))
def send_message(self, action, **data):
self.comm.send_message(action, data)
def connect_links(self):
for a in document.body.querySelectorAll(f'a[{self.link_attr}]'):
a.addEventListener('click', self.link_activated)
if runtime.is_standalone_viewer:
# links with a target get turned into requests to open a new window by Qt
for a in document.body.querySelectorAll('a[target]'):
a.removeAttribute('target')
def listen_for_image_double_clicks(self):
for img in document.querySelectorAll('img, image'):
img.addEventListener('dblclick', self.image_double_clicked, {'passive': True})
def image_double_clicked(self, ev):
img = ev.currentTarget
if img.dataset.calibreSrc:
self.send_message('view_image', calibre_src=img.dataset.calibreSrc)
def link_activated(self, evt):
try:
data = JSON.parse(evt.currentTarget.getAttribute(self.link_attr))
except:
print('WARNING: Failed to parse link data {}, ignoring'.format(evt.currentTarget?.getAttribute?(self.link_attr)))
return
self.activate_link(data.name, data.frag, evt.currentTarget)
def activate_link(self, name, frag, target_elem):
if not name:
name = current_spine_item().name
try:
is_popup = is_footnote_link(target_elem, name, frag, current_spine_item().name, self.book.manifest.link_to_map or {})
except:
import traceback
traceback.print_exc()
is_popup = False
if is_popup:
self.on_fake_popup_activation({'name': name, 'frag': frag, 'title': target_elem.textContent})
return
if name is current_spine_item().name:
self.replace_history_on_next_cfi_update = False
self.scroll_to_anchor(frag)
else:
self.send_message('scroll_to_anchor', name=name, frag=frag)
def on_fake_popup_activation(self, data):
self.send_message(
'show_footnote', name=data.name, frag=data.frag, title=data.title,
cols_per_screen=calc_columns_per_screen(), rtl=scroll_viewport.rtl,
vertical_writing_mode=scroll_viewport.vertical_writing_mode
)
def scroll_to_anchor(self, frag):
if frag:
elem = document.getElementById(frag)
if not elem:
c = document.getElementsByName(frag)
if c and c.length:
elem = c[0]
if elem:
scroll_to_elem(elem)
else:
scroll_viewport.scroll_to(0, 0)
def scroll_to_ref(self, refnum):
refnum = int(refnum)
elem = elem_for_ref(refnum)
if elem:
scroll_to_elem(elem)
def on_scroll_to_ref(self, data):
refnum = data.refnum
if refnum?:
self.scroll_to_ref(refnum)
def show_search_result(self, data, from_load):
if self.load_search_result_timer:
window.clearTimeout(self.load_search_result_timer)
self.load_search_result_timer = None
sr = data.search_result
if sr.on_discovery:
if sr.result_num is 1:
self.full_book_search_in_progress = FullBookSearch()
elif self.full_book_search_in_progress?.first_result_shown:
return
self.last_search_at = window.performance.now()
before_select_pos = {'x': scroll_viewport.x(), 'y': scroll_viewport.y()}
if select_search_result(sr):
self.ensure_selection_boundary_visible()
need_workaround = from_load and current_layout_mode() is 'paged'
if need_workaround:
# workaround bug in chrome where sizes are incorrect in paged
# mode on initial load for some books
self.load_search_result_timer = window.setTimeout(self.ensure_search_result_visible.bind(None, before_select_pos), int(3 * ONSCROLL_DEBOUNCE_TIME / 4))
if self.full_book_search_in_progress and not self.full_book_search_in_progress.first_result_shown and sr.on_discovery:
discovered = False
if progress_frac() >= self.full_book_search_in_progress.progress_frac_at_start or current_spine_item().index is not self.full_book_search_in_progress.start_spine_index:
self.full_book_search_in_progress.first_result_shown = True
discovered = True
else:
scroll_viewport.scroll_to(before_select_pos.x, before_select_pos.y)
self.send_message('search_result_discovered', search_result=data.search_result, discovered=discovered)
if not need_workaround:
self.add_search_result_to_history_stack(before_select_pos)
else:
self.send_message('search_result_not_found', search_result=data.search_result)
def add_search_result_to_history_stack(self, before_select_pos):
self.replace_history_on_next_cfi_update = False
self.update_cfi(True)
def ensure_search_result_visible(self, before_select_pos):
self.load_search_result_timer = None
sel = window.getSelection()
if sel.isCollapsed or sel.rangeCount is 0:
return
self.ensure_selection_boundary_visible()
self.add_search_result_to_history_stack(before_select_pos)
def set_reference_mode(self, data):
self.reference_mode_enabled = data.enabled
if data.enabled:
start_reference_mode()
else:
end_reference_mode()
def apply_highlight(self, uuid, existing, has_notes, style):
sel = window.getSelection()
if not sel.rangeCount:
return
anchor_before = find_anchor_before_range(sel.getRangeAt(0), self.book.manifest.toc_anchor_map, self.anchor_funcs)
text = sel.toString()
bounds = cfi_for_selection()
style = highlight_style_as_css(style, opts.is_dark_theme, opts.color_scheme.foreground)
cls = 'crw-has-dot' if has_notes else None
annot_id, intersecting_wrappers = wrap_text_in_range(style, None, cls, self.add_highlight_listeners)
removed_highlights = v'[]'
if annot_id is not None:
intersecting_uuids = {annot_id_uuid_map[x]:True for x in intersecting_wrappers}
if existing and intersecting_uuids[existing]:
uuid = existing
elif intersecting_wrappers.length is 1 and annot_id_uuid_map[intersecting_wrappers[0]]:
uuid = annot_id_uuid_map[intersecting_wrappers[0]]
intersecting_wrappers = v'[]'
removed_highlights = {}
for crw in intersecting_wrappers:
unwrap_crw(crw)
if annot_id_uuid_map[crw] and annot_id_uuid_map[crw] is not uuid:
removed_highlights[annot_id_uuid_map[crw]] = True
v'delete annot_id_uuid_map[crw]'
removed_highlights = Object.keys(removed_highlights)
sel.removeAllRanges()
annot_id_uuid_map[annot_id] = uuid
self.send_message(
'annotations',
type='highlight-applied',
uuid=uuid, ok=annot_id is not None,
bounds=bounds,
removed_highlights=removed_highlights,
highlighted_text=text,
anchor_before=anchor_before
)
reset_find_caches()
def annotations_msg_received(self, data):
dtype = data?.type
if dtype is 'move-end-of-selection':
move_end_of_selection(data.pos, data.start)
elif dtype is 'set-highlight-style':
set_selection_style(data.style)
elif dtype is 'trigger-shortcut':
self.on_handle_navigation_shortcut(data)
elif dtype is 'extend-to-paragraph':
sel = window.getSelection()
try:
try:
sel.modify('extend', 'forward', 'paragraphboundary')
except:
sel.modify('extend', 'forward', 'lineboundary')
end_node, end_offset = sel.focusNode, sel.focusOffset
try:
sel.modify('extend', 'backward', 'paragraphboundary')
except:
sel.modify('extend', 'backward', 'lineboundary')
sel.setBaseAndExtent(sel.focusNode, sel.focusOffset, end_node, end_offset)
except:
(console.error or console.log)('Failed to extend selection to paragraph')
elif dtype is 'extend-to-point':
move_end_of_selection(data.pos, is_start_closer_to_point(data.pos))
elif dtype is 'drag-scroll':
self.scroll_to_extend_annotation(data.backwards)
elif dtype is 'edit-highlight':
crw_ = {v: k for k, v in Object.entries(annot_id_uuid_map)}[data.uuid]
if crw_ and select_crw(crw_):
self.ensure_selection_visible()
window.setTimeout(def():
self.send_message('annotations', type='edit-highlight')
, 50)
else:
self.send_message('annotations', type='edit-highlight-failed', uuid=data.uuid)
elif dtype is 'notes-edited':
cls = 'crw-has-dot'
crw_ = {v: k for k, v in Object.entries(annot_id_uuid_map)}[data.uuid]
if crw_:
node = last_span_for_crw(crw_)
if node:
if data.has_notes:
node.classList.add(cls)
else:
node.classList.remove(cls)
elif dtype is 'remove-highlight':
crw_ = {v: k for k, v in Object.entries(annot_id_uuid_map)}[data.uuid]
if crw_:
unwrap_crw(crw_)
v'delete annot_id_uuid_map[crw_]'
# have to remove selection otherwise selection bar does
# not hide itself on multiline selections
window.getSelection().removeAllRanges()
elif dtype is 'apply-highlight':
existing = all_annots_in_selection(window.getSelection(), annot_id_uuid_map)
if existing.length is 0 or (existing.length is 1 and existing[0] is data.existing):
self.apply_highlight(data.uuid, data.existing, data.has_notes, data.style)
else:
self.send_message(
'annotations', type='highlight-overlapped',
uuid=data.uuid, existing=data.existing, has_notes=data.has_notes, style=data.style)
elif dtype is 'apply-highlight-overwrite':
self.apply_highlight(data.uuid, data.existing, data.has_notes, data.style)
elif dtype is 'cite-current-selection':
sel = window.getSelection()
if not sel.rangeCount:
return
bounds = cfi_for_selection()
text = sel.toString()
self.send_message('annotations', type='cite-data', bounds=bounds, highlighted_text=text)
else:
console.log('Ignoring annotations message to iframe with unknown type: ' + dtype)
def apply_highlights_on_load(self, highlights):
clear_annot_id_uuid_map()
reset_highlight_counter()
strcmp = v'new Intl.Collator().compare'
highlights.sort(def (a, b): return strcmp(a.timestamp, b.timestamp);)
for h in highlights:
r = range_from_cfi(h.start_cfi, h.end_cfi)
if not r:
continue
style = highlight_style_as_css(h.style, opts.is_dark_theme, opts.color_scheme.foreground)
cls = 'crw-has-dot' if h.notes else None
annot_id, intersecting_wrappers = wrap_text_in_range(style, r, cls, self.add_highlight_listeners)
if annot_id is not None:
annot_id_uuid_map[annot_id] = h.uuid
for crw in intersecting_wrappers:
unwrap_crw(crw)
v'delete annot_id_uuid_map[crw]'
def replace_highlights(self, data):
highlights = data.highlights
unwrap_all_crw()
self.apply_highlights_on_load(highlights or v'[]')
def add_highlight_listeners(self, wrapper):
wrapper.addEventListener('dblclick', self.highlight_wrapper_dblclicked)
def highlight_wrapper_dblclicked(self, ev):
crw = ev.currentTarget.dataset.calibreRangeWrapper
if crw:
ev.preventDefault(), ev.stopPropagation()
select_crw(crw)
def copy_selection(self):
try:
if document.execCommand('copy'):
return
except:
pass
s = window.getSelection()
text = s.toString()
if text:
container = document.createElement('div')
for i in range(s.rangeCount):
container.appendChild(s.getRangeAt(i).cloneContents())
self.send_message('copy_text_to_clipboard', text=text, html=container.innerHTML)
def tts_msg_received(self, data):
if data.type is 'mark':
self.mark_word_being_spoken(data.num)
elif data.type is 'play':
text_node, offset = None, 0
if data.pos:
r = word_at_point(data.pos.x, data.pos.y)
else:
r = first_visible_word()
if r and r.startContainer?.nodeType is Node.TEXT_NODE:
text_node, offset = r.startContainer, r.startOffset
marked_text = tts_data(text_node, offset)
sel = window.getSelection()
sel.removeAllRanges()
self.send_message('tts', type='text-extracted', marked_text=marked_text, pos=data.pos)
elif data.type is 'trigger-shortcut':
self.on_handle_navigation_shortcut(data)
def mark_word_being_spoken(self, occurrence_number):
self.last_search_at = window.performance.now()
if select_tts_mark(occurrence_number):
self.ensure_selection_boundary_visible()
def hints_msg_received(self, data):
if data.type is 'show':
# clear selection so that it does not confuse with the hints which use the same colors
window.getSelection().removeAllRanges()
hints_map = hint_visible_links()
self.send_message('hints', type='shown', hints_map=hints_map)
elif data.type is 'hide':
unhint_links()
elif data.type is 'activate':
hint = data.hint
if hint.type is 'link':
a = document.body.querySelector(f'[data-calibre-hint-value="{hint.value}"]')
a.removeEventListener('animationend', self.hint_animation_ended)
a.addEventListener('animationend', self.hint_animation_ended, False)
a.classList.add('calibre-animated-hint')
elif data.type is 'apply_prefix':
apply_prefix_to_hints(data.prefix)
def hint_animation_ended(self, ev):
a = ev.currentTarget
a.classList.remove('calibre-animated-hint')
a.click()
def main():
if not main.boss:
main.boss = IframeBoss()