mirror of
https://github.com/kovidgoyal/calibre.git
synced 2026-02-15 15:59:38 -05:00
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.
1018 lines
44 KiB
Plaintext
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()
|