# 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 elementmaker import E
from gettext import gettext as _

import read_book.iframe  # noqa
from ajax import ajax_send
from book_list.globals import get_session_data
from book_list.home import update_book_in_recently_read_by_user_on_home_page
from book_list.theme import cached_color_to_rgba, get_color, set_ui_colors
from book_list.ui import show_panel
from dom import add_extra_css, build_rule, clear, set_css, svgicon, unique_id
from iframe_comm import create_wrapped_iframe
from modals import error_dialog, warning_dialog
from read_book.annotations import AnnotationsManager
from read_book.bookmarks import create_new_bookmark
from read_book.content_popup import ContentPopupOverlay
from read_book.globals import (
    current_book, current_spine_item, is_dark_theme, rtl_page_progression, runtime,
    set_current_spine_item, ui_operations
)
from read_book.goto import get_next_section
from read_book.highlights import get_current_link_prefix, link_to_epubcfi
from read_book.hints import Hints
from read_book.open_book import add_book_to_recently_viewed
from read_book.overlay import Overlay
from read_book.prefs.colors import resolve_color_scheme
from read_book.prefs.font_size import change_font_size_by, restore_default_font_size
from read_book.prefs.fonts import current_zoom_step_size
from read_book.prefs.head_foot import render_head_foot
from read_book.prefs.scrolling import (
    MIN_SCROLL_SPEED_AUTO as SCROLL_SPEED_STEP, change_scroll_speed
)
from read_book.prefs.user_stylesheet import modify_background_image_url_for_fetch
from read_book.read_aloud import ReadAloud
from read_book.read_audio_ebook import ReadAudioEbook
from read_book.resources import load_resources
from read_book.scrollbar import BookScrollbar
from read_book.search import SearchOverlay
from read_book.selection_bar import SelectionBar
from read_book.shortcuts import create_shortcut_map
from read_book.smil import get_smil_audio_map
from read_book.timers import Timers
from read_book.toc import get_current_toc_nodes, update_visible_toc_nodes, get_current_pagelist_items
from read_book.touch import set_left_margin_handler, set_right_margin_handler
from session import get_device_uuid, get_interface_data, all_settings
from utils import (
    default_context_menu_should_be_allowed, html_escape, parse_url_params,
    safe_set_inner_html, username_key
)
from viewer.constants import CHARS_PER_PAGE

add_extra_css(def ():
    sel = '.book-side-margin'
    ans = build_rule(
        sel, cursor='pointer', text_align='center', height='100dvh', user_select='none',
        display='flex', align_items='center', justify_content='space-between', flex_direction='column',
        flex_shrink='0',
    )
    ans += build_rule(sel + ' > .arrow', display='none')
    ans += build_rule(sel + ' > *', max_width='100%', overflow='hidden')
    ans += build_rule(sel + ':hover > .not-arrow', display='none')
    ans += build_rule(sel + ':active > .not-arrow', display='none')
    ans += build_rule(sel + ':hover > .arrow', display='block')
    ans += build_rule(sel + ':active > .arrow', display='block', transform='scale(2)')
    ans += build_rule('.book-h-margin .visible-on-hover', visibility='hidden')
    ans += build_rule('.book-h-margin:hover .visible-on-hover', visibility='visible')
    ans += build_rule('.book-h-margin .visible-on-hover:hover', color=get_color('window-hover-foreground'))
    return ans
)


# Simple Overlays {{{


def show_controls_help():
    container = document.getElementById('controls-help-overlay')
    container.style.display = 'block'
    container.style.backgroundColor = get_color('window-background')
    if not show_controls_help.listener_added:
        show_controls_help.listener_added = True
        container.addEventListener('click', def ():
            document.getElementById('controls-help-overlay').style.display = 'none'
            ui_operations.focus_iframe()
        )
        container.addEventListener('contextmenu', def (evt):
            evt.preventDefault(), evt.stopPropagation()
            document.getElementById('controls-help-overlay').style.display = 'none'
            ui_operations.focus_iframe()
        , {'passive': False})
        container.addEventListener('keydown', def (event):
            event.preventDefault(), event.stopPropagation()
            document.getElementById('controls-help-overlay').style.display = 'none'
            ui_operations.focus_iframe()
        , {'passive': False})

    def focus():
        if container.style.display is 'block':
            container.querySelector('input').focus()
            window.setTimeout(focus, 10)

    if runtime.is_standalone_viewer:
        clear(container)
        container.appendChild(E.div(
            style='margin: 1rem',
            E.div(style='margin-top: 1rem'),
            E.div(style='margin-top: 1rem'),
            E.div(style='margin-top: 1rem'),
            E.div(style='margin-top: 1rem'),
            E.input(style='background: transparent; border-width: 0; outline: none', readonly='readonly'),
        ))
        div = container.lastChild.firstChild
        safe_set_inner_html(div, _('Welcome to the <b>calibre E-book viewer</b>!'))
        div = div.nextSibling
        safe_set_inner_html(div, _('Use the <b>PageUp/PageDn</b> or <b>Arrow keys</b> to turn pages'))
        div = div.nextSibling
        safe_set_inner_html(div, _('Press the <b>Esc</b> key or <b>{}</b> or <b>tap on the top third</b> of the text area to show the viewer controls').format(
            _('control+click') if 'macos' in window.navigator.userAgent else _('right click')
        ))
        div = div.nextSibling
        safe_set_inner_html(div, _('Press any key to continue…'))
        focus()
        return

    def msg(txt):
        return set_css(E.div(txt), padding='1ex 1em', text_align='center', margin='auto')

    left_msg = msg(_('Tap to turn back'))
    left_width = 'min(25vw, 1in)'
    right_msg = msg(_('Tap to turn page'))
    right_width = 'auto'
    left_grow = 0
    right_grow = 1
    if rtl_page_progression():
        left_msg, right_msg = right_msg, left_msg
        left_width, right_width = right_width, left_width
        left_grow, right_grow = right_grow, left_grow

    # Clear it out if this is not the first time it's created.
    # Needed to correctly show it again in a different page progression direction.
    if container.firstChild:
        container.removeChild(container.firstChild)

    container.appendChild(E.div(
        style=f'overflow: hidden; width: 100vw; height: 100dvh; text-align: center;'
        + f' font-size: 1.3rem; font-weight: bold; background: {get_color("window-background")};'
        + 'display:flex; flex-direction: column; align-items: stretch',
        E.div(
            msg(_('Tap (or right click) for controls')),
            style='height: 25dvh; display:flex; align-items: center; border-bottom: solid 2px currentColor',
        ),
        E.div(
            style='display: flex; align-items: stretch; flex-grow: 10',
            E.div(left_msg, style=f'width: {left_width}; flex-grow: {left_grow}; display:flex; align-items: center; border-right: solid 2px currentColor',),
            E.div(right_msg, style=f'width: {right_width}; display:flex; flex-grow: {right_grow}; align-items: center',),
        ),
    ))


# }}}


def body_font_size():
    ans = body_font_size.ans
    if not ans or window.devicePixelRatio is not body_font_size.dpr:
        body_font_size.dpr = window.devicePixelRatio
        q = window.getComputedStyle(document.body).fontSize
        if q and q.endsWith('px'):
            q = parseInt(q)
            if q and not isNaN(q):
                ans = body_font_size.ans = q
                return ans
        ans = body_font_size.ans = 16
    return ans


def header_footer_font_size(sz):
    return min(max(0, sz - 6), body_font_size())


def margin_elem(sd, which, id, onclick, oncontextmenu):
    sz = sd.get(which, 20)
    fsz = header_footer_font_size(sz)
    s = '; text-overflow: ellipsis; white-space: nowrap; overflow: hidden'
    ans = E.div(
        style=f'height:{sz}px; overflow: hidden; font-size:{fsz}px; width:100%; padding: 0;'
        + ' display: flex; justify-content: space-between; align-items: center; user-select: none; cursor: pointer',
        class_='book-h-margin',
        id=id,
        E.div(style='margin-right: 1.5em' + s),
        E.div(style=s),
        E.div(style='margin-left: 1.5em' + s)
    )
    if onclick:
        ans.addEventListener('click', onclick)
    if oncontextmenu:
        ans.addEventListener('contextmenu', oncontextmenu)
    return ans


def side_margin_elem(self, sd, which, icon):
    ans = E.div(
        E.div(class_='arrow', style='order: 3', svgicon(f'caret-{icon}', '100%', '100%')),
        E.div(style='order:1'), E.div(style='order:2', class_='not-arrow'), E.div(style='order:4'),
        style='width:{}px; user-select: none'.format(sd.get(f'margin_{which}', 20)),
        class_='book-side-margin', id=f'book-{which}-margin',
        onclick=self.side_margin_clicked.bind(None, which),
        oncontextmenu=self.margin_context_menu.bind(None, which),
        onwheel=self.on_margin_wheel.bind(None, which)
    )
    return ans


class View:
    def __init__(self, container):
        self.container = container
        self.timers = Timers()
        self.reference_mode_enabled = False
        self.loaded_resources = {}
        self.current_progress_frac = self.current_file_progress_frac = 0
        self.current_page_counts = {'current': 0, 'total': 0, 'pages_per_screen': 1}
        self.current_status_message = ''
        self.unsynced_message = ''
        self.last_position_data = None
        self.current_toc_node = self.current_toc_toplevel_node = None
        self.current_toc_families = []
        self.report_cfi_callbacks = {}
        self.get_cfi_counter = 0
        self.show_loading_callback_timer = None
        self.timer_ids = {'clock': 0}
        self.book_scrollbar = BookScrollbar(self)
        sd = get_session_data()
        self.keyboard_shortcut_map = create_shortcut_map(sd.get('keyboard_shortcuts'))
        if ui_operations.export_shortcut_map:
            ui_operations.export_shortcut_map(self.keyboard_shortcut_map)
        left_margin = side_margin_elem(self, sd, 'left', 'right' if sd.get('reverse_page_turn_zones') else 'left')
        set_left_margin_handler(left_margin)
        right_margin = side_margin_elem(self, sd, 'right', 'left' if sd.get('reverse_page_turn_zones') else 'right')
        set_right_margin_handler(right_margin)
        handlers = {
            'autoscroll_state_changed': def (data):
                self.autoscroll_active = v'!!data.running'
                if ui_operations.autoscroll_state_changed:
                    ui_operations.autoscroll_state_changed(self.autoscroll_active)
            ,
            'bump_font_size': self.bump_font_size,
            'content_loaded': self.on_content_loaded,
            'error': self.on_iframe_error,
            'invisible_text': self.on_invisible_text,
            'goto_doc_boundary': def (data): self.goto_doc_boundary(data.start);,
            'handle_keypress': self.on_handle_keypress,
            'handle_shortcut': self.on_handle_shortcut,
            'human_scroll': self.on_human_scroll,
            'next_section': self.on_next_section,
            'next_spine_item': self.on_next_spine_item,
            'print': self.on_print,
            'ready': self.on_iframe_ready,
            'report_cfi': self.on_report_cfi,
            'request_size': self.on_request_size,
            'scroll_to_anchor': self.on_scroll_to_anchor,
            'link_to_missing_activated': self.on_link_to_missing_activated,
            'selectionchange': self.on_selection_change,
            'update_selection_position': self.update_selection_position,
            'columns_per_screen_changed': self.on_columns_per_screen_changed,
            'show_chrome': self.show_chrome,
            'show_footnote': self.on_show_footnote,
            'update_cfi': self.on_update_cfi,
            'update_progress_frac': self.on_update_progress_frac,
            'update_toc_position': self.on_update_toc_position,
            'search_result_not_found': self.search_result_not_found,
            'search_result_discovered': self.search_result_discovered,
            'annotations': self.on_annotations_message,
            'tts': self.on_tts_message,
            'audio_ebook_message': self.on_audio_ebook_message,
            'hints': self.on_hints_message,
            'copy_text_to_clipboard': def (data):
                ui_operations.copy_selection(data.text, data.html)
            ,
            'view_image': def (data):
                if ui_operations.view_image:
                    ui_operations.view_image(data.calibre_src)
            ,
        }

        iframe_id = unique_id('read-book-iframe')
        if runtime.is_standalone_viewer:
            entry_point = f'{runtime.FAKE_PROTOCOL}://{runtime.SANDBOX_HOST}/book/__index__'
        else:
            entry_point = 'read_book.iframe'
        iframe_kw = {
            'id': iframe_id, 'seamless': True,
            'sandbox': 'allow-popups allow-scripts allow-popups-to-escape-sandbox',
            'style': 'flex-grow: 2; width: 100%; max-width: 100%; min-width: 0; overflow: hidden;', 'allowfullscreen': 'true',
        }
        iframe, self.iframe_wrapper = create_wrapped_iframe(handlers, _('Bootstrapping book reader...'), entry_point, iframe_kw)
        container.appendChild(
            # container for horizontally aligned panels
            E.div(style='max-height: 100dvh; width: 100%; max-width: 100%; height: 100dvh;'
                  + ' overflow: hidden; overflow-x: hidden; display: flex; align-items: stretch',
                oncontextmenu=def (ev):
                    if not default_context_menu_should_be_allowed(ev):
                        ev.preventDefault()
                ,
                # container for iframe and any other panels in the same column
                E.div(style='max-height: 100dvh; min-width: 0; display: flex; flex-direction: column; align-items: stretch; flex-grow:2',
                    # container for iframe and its overlay
                    E.div(style='position: relative; max-height: 100dvh; min-width: 0; flex-grow: 2; display:flex; align-items: stretch; overflow: hidden',
                        left_margin,
                        # container for top and bottom margins
                        E.div(style='min-width: 0; flex-grow:2; display:flex; align-items:stretch; flex-direction: column; overflow-x: hidden',
                            margin_elem(sd, 'margin_top', 'book-top-margin', self.top_margin_clicked, self.margin_context_menu.bind(None, 'top')),
                            iframe,
                            margin_elem(sd, 'margin_bottom', 'book-bottom-margin', self.bottom_margin_clicked, self.margin_context_menu.bind(None, 'bottom')),
                        ),
                        right_margin,
                        self.book_scrollbar.create(),
                        # selection bar overlay
                        E.div(style='position: absolute; top:0; left:0; width: 100%; height: 100%; display:none;', id='book-selection-bar-overlay'),
                        # read aloud overlay
                        E.div(style='position: absolute; top:0; left:0; width: 100%; height: 100%; display:none;', id='book-read-aloud-overlay'),
                        E.div(style='position: absolute; top:0; left:0; width: 100%; height: 100%; display:none;', id='book-hints-overlay'),  # hints overlay
                        # search overlay
                        E.div(style='position: absolute; top:0; left:0; width: 100%; height:100%; display:none', id=SearchOverlay.CONTAINER_ID),
                        # content popup overlay
                        E.div(style='position: absolute; top:0; left:0; width: 100%; height: 100%; display:none', id='book-content-popup-overlay'),
                        # main overlay
                        E.div(style='position: absolute; top:0; left:0; width: 100%; height: 100%; overflow: auto; display:none', id='book-overlay'),
                        # controls help overlay
                        E.div(style='position: absolute; top:0; left:0; width: 100%; height: 100%; display:none', id='controls-help-overlay'),
                        # read audio ebook overlay
                        E.div(style='position: absolute; bottom:0em; width: 100%; height: 100%; display:none', id='audio-ebooks-overlay'),
                    )
                ),
            ),
        )
        self.current_color_scheme = resolve_color_scheme()
        if runtime.is_standalone_viewer:
            document.documentElement.addEventListener('keydown', self.handle_keypress, {'passive': False})
            _hl_colors = all_settings.ui_highlight_colors.default
            set_ui_colors(self.current_color_scheme.is_dark_theme, _hl_colors.dark if self.current_color_scheme.is_dark_theme else _hl_colors.light)
        is_dark_theme(self.current_color_scheme.is_dark_theme)
        self.search_overlay = SearchOverlay(self)
        self.content_popup_overlay = ContentPopupOverlay(self)
        self.overlay = Overlay(self)
        self.selection_bar = SelectionBar(self)
        self.read_aloud = ReadAloud(self)
        self.read_audio_ebook = ReadAudioEbook(self)
        self.hints = Hints(self)
        self.modal_overlays = [self.selection_bar, self.read_aloud, self.hints, self.read_audio_ebook]
        self.processing_spine_item_display = False
        self.pending_load = None
        self.currently_showing = {'selection': {'empty': True}, 'on_load': []}
        self.book_scrollbar.apply_visibility()
        self.annotations_manager = AnnotationsManager(self)

    @property
    def iframe(self):
        return self.iframe_wrapper.iframe

    def copy_to_clipboard(self):
        self.iframe_wrapper.send_message('copy_selection')

    def show_not_a_library_book_error(self):
        error_dialog(_('Not a calibre library book'), _(
            'This book is not a part of a calibre library, so no calibre:// URL for it exists.'))

    def copy_current_location_to_clipboard(self, as_url):
        link_prefix = get_current_link_prefix()
        if not link_prefix and as_url:
            return self.show_not_a_library_book_error()
        self.get_current_cfi('copy-location-url', def (req_id, data):
            if as_url:
                text = link_to_epubcfi(data.cfi, link_prefix)
            else:
                text = data.cfi
            ui_operations.copy_selection(text)
        )

    def set_scrollbar_visibility(self, visible):
        sd = get_session_data()
        sd.set('book_scrollbar', bool(visible))
        self.book_scrollbar.apply_visibility()

    def toggle_scrollbar(self):
        sd = get_session_data()
        self.set_scrollbar_visibility(not sd.get('book_scrollbar'))

    def on_annotations_message(self, data):
        self.selection_bar.handle_message(data)

    def on_tts_message(self, data):
        self.read_aloud.handle_message(data)

    def on_audio_ebook_message(self, data):
        self.read_audio_ebook.handle_message(data)

    def on_hints_message(self, data):
        self.hints.handle_message(data)

    def side_margin_clicked(self, which, event):
        backwards = which is 'left'
        if get_session_data().get('reverse_page_turn_zones'):
            backwards = not backwards
        if event.button is 0:
            event.preventDefault(), event.stopPropagation()
            sd = get_session_data()
            self.iframe_wrapper.send_message(
                'next_screen', backwards=backwards,
                flip_if_rtl_page_progression=True,
                all_pages_on_screen=sd.get('paged_margin_clicks_scroll_by_screen'))
        elif event.button is 2:
            event.preventDefault(), event.stopPropagation()
            window.setTimeout(self.show_chrome, 0)
        self.focus_iframe()

    def top_margin_clicked(self, event):
        if event.button is 0 or event.button is 2:
            event.preventDefault(), event.stopPropagation()
            self.show_chrome()
        else:
            self.focus_iframe()

    def bottom_margin_clicked(self, event):
        if event.button is 0 or event.button is 2:
            event.preventDefault(), event.stopPropagation()
            window.setTimeout(self.show_chrome, 0)
        self.focus_iframe()

    def margin_context_menu(self, which, event):
        event.preventDefault(), event.stopPropagation()
        self.show_chrome()

    def on_margin_wheel(self, which, event):
        event.preventDefault()
        self.send_wheel_event_to_iframe(event, f'margin-{which}')

    def send_wheel_event_to_iframe(self, event, location):
        evt = {'location': location}
        for attr in ('deltaX', 'deltaY', 'deltaMode', 'altKey', 'ctrlKey', 'shiftKey', 'metaKey'):
            evt[attr] = event[attr]
        self.iframe_wrapper.send_message('fake_wheel_event', evt=evt)

    def forward_gesture(self, gesture):
        self.iframe_wrapper.send_message('gesture_from_margin', gesture=gesture)

    def native_gesture(self, gesture):
        self.iframe_wrapper.send_message('native_gesture', gesture=gesture)

    def iframe_size(self):
        iframe = self.iframe
        l, r = document.getElementById('book-left-margin'), document.getElementById('book-right-margin')
        w = r.offsetLeft - l.offsetLeft - iframe.offsetLeft
        t, b = document.getElementById('book-top-margin'), document.getElementById('book-bottom-margin')
        h = b.offsetTop - t.offsetTop - iframe.offsetTop
        return w, h

    def on_request_size(self, data):
        # On iOS/Safari window.innerWidth/Height are incorrect inside an iframe
        window.scrollTo(0, 0)  # ensure the window is at 0 because otherwise it sometimes moves down a bit on mobile thanks to the disappearing nav bar
        w, h = self.iframe_size()
        self.iframe_wrapper.send_message('window_size', width=w, height=h)

    def on_print(self, data):
        print(data.string)

    def on_human_scroll(self, data):
        if data.scrolled_by_frac is None:
            self.timers.reset_read_timer()
        else:
            name = self.currently_showing.name
            length = self.book.manifest.files[name]?.length
            if length:
                amt_scrolled = data.scrolled_by_frac * length
                self.timers.on_human_scroll(amt_scrolled)

    def on_handle_keypress(self, data):
        self.handle_keypress(data.evt)

    def handle_keypress(self, evt):
        if self.overlay.is_visible and evt.key is 'Escape':
            if self.overlay.handle_escape():
                if evt.preventDefault:
                    evt.preventDefault(), evt.stopPropagation()

    def overlay_visibility_changed(self, visible):
        if self.iframe_wrapper.send_message:
            self.iframe_wrapper.send_message('overlay_visibility_changed', visible=visible)
        if ui_operations.overlay_visibility_changed:
            ui_operations.overlay_visibility_changed(visible)
        if visible:
            for x in self.modal_overlays:
                x.hide()
        else:
            self.selection_bar.update_position()

    def on_handle_shortcut(self, data):
        if not data.name:
            return
        if data.name is 'back':
            window.history.back()
        elif data.name is 'forward':
            window.history.forward()
        elif data.name is 'show_chrome':
            self.show_chrome()
        elif data.name is 'show_chrome_force':
            self.show_chrome_force()
        elif data.name is 'toggle_toc':
            ui_operations.toggle_toc()
        elif data.name is 'toggle_bookmarks':
            if ui_operations.toggle_bookmarks:
                ui_operations.toggle_bookmarks()
            else:
                self.overlay.show_bookmarks()
        elif data.name is 'show_profiles':
            self.overlay.show_profiles()
        elif data.name is 'toggle_highlights':
            ui_operations.toggle_highlights()
        elif data.name is 'new_bookmark':
            self.new_bookmark()
        elif data.name is 'copy_to_clipboard':
            self.copy_to_clipboard()
        elif data.name is 'copy_location_to_clipboard' or data.name is 'copy_location_as_url_to_clipboard':
            self.copy_current_location_to_clipboard('url' in data.name)
        elif data.name is 'toggle_inspector':
            ui_operations.toggle_inspector()
        elif data.name is 'toggle_lookup':
            ui_operations.toggle_lookup()
        elif data.name is 'toggle_full_screen':
            ui_operations.toggle_full_screen()
        elif data.name is 'toggle_paged_mode':
            self.toggle_paged_mode()
        elif data.name is 'toggle_toolbar':
            self.toggle_toolbar()
        elif data.name is 'toggle_scrollbar':
            self.toggle_scrollbar()
        elif data.name is 'quit':
            ui_operations.quit()
        elif data.name is 'start_search':
            self.show_search()
        elif data.name is 'next_match':
            ui_operations.find_next()
        elif data.name is 'previous_match':
            ui_operations.find_next(True)
        elif data.name is 'increase_font_size':
            self.bump_font_size({'increase': True})
        elif data.name is 'decrease_font_size':
            self.bump_font_size({'increase': False})
        elif data.name is 'default_font_size':
            restore_default_font_size()
        elif data.name is 'toggle_full_screen':
            ui_operations.toggle_full_screen()
        elif data.name is 'toggle_reference_mode':
            self.toggle_reference_mode()
        elif data.name is 'read_aloud':
            self.start_read_aloud()
        elif data.name is 'toggle_hints':
            self.toggle_hints()
        elif data.name is 'toggle_read_aloud':
            self.toggle_read_aloud()
        elif data.name is 'reload_book':
            self.reload_book()
        elif data.name is 'sync_book':
            self.overlay.sync_book()
        elif data.name is 'next_section':
            self.on_next_section({'forward': True})
        elif data.name is 'previous_section':
            self.on_next_section({'forward': False})
        elif data.name is 'open_book':
            self.overlay.open_book()
        elif data.name is 'next':
            self.iframe_wrapper.send_message(
                'next_screen', backwards=False,
                flip_if_rtl_page_progression=False,
                all_pages_on_screen=get_session_data().get('paged_margin_clicks_scroll_by_screen'))
        elif data.name is 'previous':
            self.iframe_wrapper.send_message(
                'next_screen', backwards=True,
                flip_if_rtl_page_progression=False,
                all_pages_on_screen=get_session_data().get('paged_margin_clicks_scroll_by_screen'))
        elif data.name is 'clear_selection':
            self.iframe_wrapper.send_message('clear_selection')
        elif data.name is 'print':
            ui_operations.print_book()
        elif data.name is 'preferences':
            self.show_chrome({'initial_panel': 'show_prefs'})
        elif data.name is 'metadata':
            self.overlay.show_metadata()
        elif data.name is 'edit_book':
            self.edit_book()
        elif data.name is 'goto_location':
            self.overlay.show_ask_for_location()
        elif data.name is 'select_all':
            self.iframe_wrapper.send_message('modify_selection', granularity='all')
        elif data.name.startsWith('shrink_selection_by_'):
            self.iframe_wrapper.send_message('modify_selection', direction='backward', granularity=data.name.rpartition('_')[-1])
        elif data.name.startsWith('extend_selection_by_'):
            self.iframe_wrapper.send_message('modify_selection', direction='forward', granularity=data.name.rpartition('_')[-1])
        elif data.name is 'extend_selection_to_start_of_line':
            self.iframe_wrapper.send_message('modify_selection', direction='backward', granularity='lineboundary')
        elif data.name is 'extend_selection_to_end_of_line':
            self.iframe_wrapper.send_message('modify_selection', direction='forward', granularity='lineboundary')
        elif data.name is 'scrollspeed_increase':
            self.update_scroll_speed(SCROLL_SPEED_STEP)
        elif data.name is 'scrollspeed_decrease':
            self.update_scroll_speed(-SCROLL_SPEED_STEP)
        elif data.name is 'toggle_autoscroll':
            self.toggle_autoscroll()
        elif data.name.startsWith('switch_color_scheme:'):
            self.switch_color_scheme(data.name.partition(':')[-1])
        elif data.name is 'increase_number_of_columns':
            self.iframe_wrapper.send_message('change_number_of_columns', delta=1)
        elif data.name is 'decrease_number_of_columns':
            self.iframe_wrapper.send_message('change_number_of_columns', delta=-1)
        elif data.name is 'reset_number_of_columns':
            self.iframe_wrapper.send_message('change_number_of_columns', delta=0)
        else:
            self.iframe_wrapper.send_message('handle_navigation_shortcut', name=data.name)

    def edit_book(self):
        ui_operations.edit_book(current_spine_item(), self.current_file_progress_frac, self.currently_showing?.selection?.text)

    def on_selection_change(self, data):
        self.currently_showing.selection = {
            'text': data.text, 'empty': data.empty, 'start': data.selection_extents.start,
            'end': data.selection_extents.end, 'annot_id': data.annot_id,
            'drag_mouse_position': data.drag_mouse_position,
            'selection_change_caused_by_search': data.selection_change_caused_by_search,
            'rtl': data.rtl, 'vertical': data.vertical,
            'start_is_anchor': data.start_is_anchor
        }
        if ui_operations.selection_changed:
            ui_operations.selection_changed(self.currently_showing.selection.text, self.currently_showing.selection.annot_id)
        self.selection_bar.update_position()

    def new_bookmark(self):
        if ui_operations.new_bookmark:
            self.get_current_cfi('new-bookmark', ui_operations.new_bookmark)
        else:
            self.get_current_cfi('new-bookmark', def (req_id, data):
                create_new_bookmark(self.annotations_manager, data)
            )

    def update_selection_position(self, data):
        sel = self.currently_showing.selection
        sel.start = data.selection_extents.start
        sel.end = data.selection_extents.end
        self.selection_bar.update_position()

    def on_columns_per_screen_changed(self, data):
        sd = get_session_data()
        cps = sd.get('columns_per_screen') or {}
        cps[data.which] = int(data.cps)
        sd.set('columns_per_screen', cps)

    def switch_color_scheme(self, name):
        get_session_data().set('current_color_scheme', name)
        ui_operations.redisplay_book()

    def toggle_paged_mode(self):
        sd = get_session_data()
        mode = sd.get('read_mode')
        new_mode = 'flow' if mode is 'paged' else 'paged'
        sd.set('read_mode', new_mode)
        ui_operations.redisplay_book()

    def toggle_autoscroll(self):
        self.iframe_wrapper.send_message('toggle_autoscroll')

    def toggle_toolbar(self):
        sd = get_session_data()
        misc = sd.get('standalone_misc_settings')
        misc.show_actions_toolbar = v'!misc.show_actions_toolbar'
        sd.set('standalone_misc_settings', misc)

    def on_invisible_text(self, data):
        warning_dialog(
                _('Not found'), _('The text: <i>{}</i> is present on this page but not visible').format(html_escape(data.text)),
                on_close=def ():
                    self.search_overlay.show()
            )

    def bump_font_size(self, data):
        mult = 1 if data.increase else -1
        frac = 0.2
        if runtime.is_standalone_viewer:
            frac = current_zoom_step_size() / 100
        change_font_size_by(mult * frac)

    def on_show_footnote(self, data):
        self.show_content_popup()
        self.content_popup_overlay.show_footnote(data)

    def hide_overlays(self):
        self.overlay.hide()
        self.search_overlay.hide()
        self.content_popup_overlay.hide()
        self.focus_iframe()

    @property
    def overlay_prevents_navigation(self):
        for x in self.modal_overlays:
            if x.is_visible and x.prevent_navigation:
                return True
        return False

    def focus_iframe(self):
        for x in self.modal_overlays:
            if x.is_visible:
                x.focus()
                return
        self.iframe.contentWindow.focus()

    def start_read_aloud(self, dont_start_talking):
        if self.book.manifest.has_smil:
            for x in self.modal_overlays:
                if x is not self.read_audio_ebook:
                    x.hide()
            self.read_audio_ebook.show()
        else:
            for x in self.modal_overlays:
                if x is not self.read_aloud:
                    x.hide()
            self.read_aloud.show()
            if not dont_start_talking:
                self.read_aloud.play()

    def toggle_read_aloud(self):
        if self.read_audio_ebook.is_visible:
            self.read_audio_ebook.hide()
        elif self.read_aloud.is_visible:
            self.read_aloud.hide()
        else:
            self.start_read_aloud()

    def toggle_hints(self):
        if self.hints.is_visible:
            self.hints.hide()
        else:
            for x in self.modal_overlays:
                if x is not self.hints:
                    x.hide()
            self.hints.show()

    def show_chrome(self, data):
        if self.content_popup_overlay.is_visible:
            self.content_popup_overlay.hide()
            return
        self.content_popup_overlay
        elements = {}
        if data and data.elements:
            elements = data.elements
        initial_panel = data?.initial_panel or None
        self.get_current_cfi('show-chrome', self.do_show_chrome.bind(None, elements, initial_panel))

    def show_chrome_force(self):
        self.hide_overlays()
        self.show_chrome()

    def do_show_chrome(self, elements, initial_panel, request_id, cfi_data):
        self.hide_overlays()
        self.update_cfi_data(cfi_data)
        if initial_panel:
            getattr(self.overlay, initial_panel)()
        else:
            self.overlay.show(elements)

    def prepare_for_close(self):
        def close_prepared(request_id, cfi_data):
            ui_operations.close_prep_finished(cfi_data.cfi)

        self.get_current_cfi('prepare-close', close_prepared)

    def show_search(self, trigger):
        self.hide_overlays()
        text = self.currently_showing.selection.text
        if runtime.is_standalone_viewer:
            ui_operations.show_search(text or '', trigger)
        else:
            if text:
                self.search_overlay.set_text(text)
            self.search_overlay.show(text)
            if trigger and text:
                self.search_overlay.find_next()

    def show_content_popup(self):
        self.hide_overlays()
        self.content_popup_overlay.show()

    def set_margins(self):
        no_margins = self.currently_showing.name is self.book.manifest.title_page_name
        sd = get_session_data()
        margin_left = 0 if no_margins else sd.get('margin_left')
        margin_right = 0 if no_margins else sd.get('margin_right')
        margin_top = 0 if no_margins else sd.get('margin_top')
        margin_bottom = 0 if no_margins else sd.get('margin_bottom')
        # Use reader container size (not window size) so side nav/top bars
        # do not skew overlay/control positioning.
        container_width = self.container.clientWidth or window.innerWidth
        container_height = self.container.clientHeight or window.innerHeight
        max_text_height = sd.get('max_text_height')
        th = container_height - margin_top - margin_bottom
        if not no_margins and max_text_height > 100 and th > max_text_height:
            extra = (th - max_text_height) // 2
            margin_top += extra
            margin_bottom += extra
        max_text_width = sd.get('max_text_width')
        tw = container_width - margin_left - margin_right
        if not no_margins and max_text_width > 100 and tw > max_text_width:
            extra = (tw - max_text_width) // 2
            margin_left += extra
            margin_right += extra
        set_css(document.getElementById('book-top-margin'), height=margin_top + 'px', font_size=header_footer_font_size(margin_top) + 'px')
        set_css(document.getElementById('book-bottom-margin'), height=margin_bottom + 'px', font_size=header_footer_font_size(margin_bottom) + 'px')

        def side_margin(which, val):
            m = document.getElementById('book-{}-margin'.format(which))
            set_css(m, width=val + 'px')
            val = min(val, 25)
            s = m.querySelector('.arrow').style
            s.width = val + 'px'
            s.height = val + 'px'
        side_margin('left', margin_left), side_margin('right', margin_right)

    def on_iframe_ready(self, data):
        data.width, data.height = self.iframe_size()
        if ui_operations.on_iframe_ready:
            ui_operations.on_iframe_ready()
        return self.do_pending_load

    def do_pending_load(self):
        if self.pending_load:
            data = self.pending_load
            self.pending_load = None
            self.show_spine_item_stage2(data)

    def on_iframe_error(self, data):
        title = data.title or _('There was an error processing the book')
        msg = _('Unknown error')
        if data.errkey:
            if data.errkey is 'no-auto-scroll-in-paged-mode':
                title = _('No auto scroll in paged mode')
                msg = _('Switch to flow mode (Viewer preferences->Page layout) to enable auto scrolling')
            elif data.errkey is 'changing-columns-in-flow-mode':
                title = _('In flow mode')
                msg = _('Cannot change number of pages per screen in flow mode, switch to paged mode first.')
            elif data.errkey = 'unhandled-error':
                window.onerror(data.msg, data.script_url, data.line_number, data.column_number,
                               traceback.unserialize_error_for_formatting(data.error_object))
                return
        if data.is_non_critical:
            warning_dialog(title, msg, data.details, on_close=ui_operations.focus_iframe)
            return
        ui_operations.show_error(title, msg, data.details)

    def apply_color_scheme(self):
        self.current_color_scheme = ans = resolve_color_scheme()
        iframe = self.iframe
        if runtime.is_standalone_viewer:
            _hl_colors = all_settings.ui_highlight_colors.default
            set_ui_colors(self.current_color_scheme.is_dark_theme, _hl_colors.dark if self.current_color_scheme.is_dark_theme else _hl_colors.light)
        else:
            iframe.style.colorScheme = 'dark' if self.current_color_scheme.is_dark_theme else 'light'

        is_dark_theme(self.current_color_scheme.is_dark_theme)
        for which in 'left top right bottom'.split(' '):
            m = document.getElementById('book-{}-margin'.format(which))
            s = m.style
            mc = ans[f'margin_{which}']
            if mc:
                s.backgroundColor, s.color = mc.split(':')
            else:
                s.color = ans.foreground
                s.backgroundColor = 'transparent'
        sd = get_session_data()
        for node in [iframe, iframe.parentNode]:
            node.style.backgroundColor = 'transparent'
            node.style.backgroundImage = 'none'
        s = iframe.parentNode.parentNode.style
        bg_image = sd.get('background_image')
        if bg_image:
            s.backgroundImage = f'url({modify_background_image_url_for_fetch(bg_image)})'
            fade = int(sd.get('background_image_fade'))
            if fade > 0:
                rgba = cached_color_to_rgba(ans.background)
                bg_image_fade = f'rgba({rgba[0]}, {rgba[1]}, {rgba[2]}, {fade/100})'
                iframe.parentNode.style.backgroundColor = bg_image_fade
                for which in 'left right'.split(' '):
                    ms = document.getElementById(f'book-{which}-margin').style
                    if ms.backgroundColor is 'transparent':
                        ms.backgroundColor = bg_image_fade
        else:
            s.backgroundColor = ans.background or 'white'
            s.backgroundImage = 'none'
        if sd.get('background_image_style') is 'scaled':
            s.backgroundSize = '100% 100%'
            s.backgroundRepeat = 'no-repeat'
            s.backgroundAttachment = 'scroll'
            s.backgroundPosition = 'center'
        else:
            s.backgroundSize = 'auto'
            s.backgroundRepeat = 'repeat'
            s.backgroundAttachment = 'scroll'
            s.backgroundPosition = '0 0'

        self.content_popup_overlay.apply_color_scheme(ans.background, ans.foreground)
        self.book_scrollbar.apply_color_scheme(ans)
        return ans

    def on_resize(self):
        if self.book and self.currently_showing.name:
            sd = get_session_data()
            if sd.get('max_text_width') or sd.get('max_text_height'):
                self.set_margins()

    def show_loading_message(self, msg):
        self.overlay.show_loading_message(msg)

    def show_loading(self):
        msg = _('Loading next section from <i>{title}</i>, please wait…').format(title=self.book.metadata.title or _('Unknown'))
        if self.show_loading_callback_timer is not None:
            clearTimeout(self.show_loading_callback_timer)
        self.show_loading_callback_timer = setTimeout(self.show_loading_message.bind(None, msg), 200)

    def hide_loading(self):
        if window.read_book_initial_open_search_text:
            q = window.read_book_initial_open_search_text
            v'delete window.read_book_initial_open_search_text'
            self.search_overlay.do_initial_search(q.text, q.query)
            self.show_loading_message(_('Searching for: {}').format(q.query))
            return
        if self.show_loading_callback_timer is not None:
            clearTimeout(self.show_loading_callback_timer)
            self.show_loading_callback_timer = None
        self.iframe.style.visibility = 'visible'
        self.overlay.hide_loading_message()
        self.focus_iframe()

    def parse_cfi(self, encoded_cfi, book):
        name = cfi = None
        if encoded_cfi and encoded_cfi.startswith('epubcfi(/'):
            cfi = encoded_cfi[len('epubcfi(/'):-1]
            snum, rest = cfi.partition('/')[::2]
            try:
                snum = int(snum)
            except Exception:
                print('Invalid spine number in CFI:', snum)
            if jstype(snum) is 'number':
                name = book.manifest.spine[(int(snum) // 2) - 1] or name
                cfi = '/' + rest
        return name, cfi

    def open_book_page(self):
        # Open the page for the current book in a new tab
        if self.book and self.book.key:
            show_panel('book_details', {'library_id': self.book.key[0], 'book_id': self.book.key[1] + ''}, replace=False)

    def clear_book_resource_caches(self):
        self.loaded_resources = {}
        self.content_popup_overlay.loaded_resources = {}

    def reload_book(self):
        self.clear_book_resource_caches()
        ui_operations.reload_book()

    def display_book(self, book, initial_position, is_redisplay):
        self.hide_overlays()
        self.iframe.focus()
        is_current_book = self.book and self.book.key == book.key
        self.book_load_started = True
        if is_current_book:
            if not is_redisplay:
                self.search_overlay.clear_caches(book)  # could be a reload
        else:
            if self.book:
                self.iframe_wrapper.reset()
                self.content_popup_overlay.reset()
            self.clear_book_resource_caches()
            self.timers.start_book(book)
            self.search_overlay.clear_caches(book)
        unkey = username_key(get_interface_data().username)
        self.book = current_book.book = book
        hl = None
        if not is_redisplay:
            if self.read_audio_ebook:
                self.read_audio_ebook.hide()
                clear(self.read_audio_ebook.container)
            if runtime.is_standalone_viewer:
                hl = book.highlights
                v'delete book.highlights'
            else:
                if unkey and book.annotations_map[unkey]:
                    hl = book.annotations_map[unkey].highlight
                    self.annotations_manager.set_bookmarks(book.annotations_map[unkey].bookmark or [])
            self.annotations_manager.set_highlights(hl or [])
            if runtime.is_standalone_viewer:
                add_book_to_recently_viewed(book)
        if ui_operations.update_last_read_time:
            ui_operations.update_last_read_time(book)
        pos = {'replace_history': True}
        if not book.manifest.spine.length:
            ui_operations.show_error(_('Invalid book'), _('This book is empty, with no items in the spine'))
            return
        name = book.manifest.spine[0]
        cfi = None
        if initial_position and initial_position.type is 'cfi' and initial_position.data.startswith('epubcfi(/'):
            cfi = initial_position.data
        else:
            q = parse_url_params()
            if q.bookpos and q.bookpos.startswith('epubcfi(/'):
                cfi = q.bookpos
            elif book.last_read_position and book.last_read_position[unkey]:
                cfi = book.last_read_position[unkey]
        cfiname, internal_cfi = self.parse_cfi(cfi, book)
        if cfiname and internal_cfi:
            name = cfiname
            pos.type, pos.cfi = 'cfi', internal_cfi
        navigated = False
        if initial_position:
            if initial_position.type is 'toc':
                navigated = self.goto_toc_node(initial_position.data)
            elif initial_position.type is 'bookpos':
                navigated = True
                self.goto_book_position(initial_position.data)
            elif initial_position.type is 'ref':
                navigated = self.goto_reference(initial_position.data)
        if navigated:
            self.hide_loading()
        else:
            self.show_name(name, initial_position=pos)
        sd = get_session_data()
        help_key = 'controls_help_shown_count' + ('_rtl_page_progression' if rtl_page_progression() else '')
        if not self[help_key]:
            self[help_key] = True
            c = sd.get(help_key, 0)
            if c < 2:
                show_controls_help()
                sd.set('controls_help_shown_count' + ('_rtl_page_progression' if rtl_page_progression() else ''), c + 1)

    def preferences_changed(self):
        self.set_margins()
        ui_operations.update_url_state(True)
        ui_operations.redisplay_book()

    def redisplay_book(self):
        # redisplay_book() is called when settings are changed
        if not self.book:
            if runtime.is_standalone_viewer:
                self.overlay.open_book()
            return
        sd = get_session_data()
        self.keyboard_shortcut_map = create_shortcut_map(sd.get('keyboard_shortcuts'))
        if ui_operations.export_shortcut_map:
            ui_operations.export_shortcut_map(self.keyboard_shortcut_map)
        self.book_scrollbar.apply_visibility()
        self.display_book(self.book, None, True)

    def iframe_settings(self, name):
        sd = get_session_data()
        cs = self.apply_color_scheme()
        return {
            'margin_left': 0 if name is self.book.manifest.title_page_name else sd.get('margin_left'),
            'margin_right': 0 if name is self.book.manifest.title_page_name else sd.get('margin_right'),
            'margin_top': 0 if name is self.book.manifest.title_page_name else sd.get('margin_top'),
            'margin_bottom': 0 if name is self.book.manifest.title_page_name else sd.get('margin_bottom'),
            'read_mode': sd.get('read_mode'),
            'columns_per_screen': sd.get('columns_per_screen'),
            'color_scheme': cs,
            'override_book_colors': sd.get('override_book_colors'),
            'is_dark_theme': cs.is_dark_theme,
            'base_font_size': sd.get('base_font_size'),
            'user_stylesheet': sd.get('user_stylesheet'),
            'keyboard_shortcuts': sd.get('keyboard_shortcuts'),
            'hide_tooltips': sd.get('hide_tooltips'),
            'cover_preserve_aspect_ratio': sd.get('cover_preserve_aspect_ratio'),
            'paged_wheel_scrolls_by_screen': sd.get('paged_wheel_scrolls_by_screen'),
            'paged_wheel_section_jumps': sd.get('paged_wheel_section_jumps'),
            'paged_pixel_scroll_threshold': sd.get('paged_pixel_scroll_threshold'),
            'lines_per_sec_auto': sd.get('lines_per_sec_auto'),
            'lines_per_sec_smooth': sd.get('lines_per_sec_smooth'),
            'scroll_auto_boundary_delay': sd.get('scroll_auto_boundary_delay'),
            'scroll_mouse_boundary_delay': sd.get('scroll_mouse_boundary_delay'),
            'scroll_stop_boundaries': sd.get('scroll_stop_boundaries'),
            'reverse_page_turn_zones': sd.get('reverse_page_turn_zones'),
            'gesture_overrides': sd.get('gesture_overrides'),
        }

    def show_name(self, name, initial_position=None):
        if self.currently_showing.loading:
            return
        self.processing_spine_item_display = False
        initial_position = initial_position or {'replace_history': False}
        spine = self.book.manifest.spine
        idx = spine.indexOf(name)
        self.currently_showing = {
            'name': name, 'settings': self.iframe_settings(name), 'initial_position': initial_position,
            'loading': True, 'spine_index': idx, 'selection': {'empty': True}, 'on_load': [],
        }
        self.timers.set_chapter(name)
        self.show_loading()
        set_current_spine_item(name)
        if idx > -1:
            self.currently_showing.bookpos = 'epubcfi(/{})'.format(2 * (idx + 1))
        self.set_margins()
        self.load_doc(name, self.show_spine_item)

    def load_doc(self, name, done_callback):
        def cb(resource_data):
            self.loaded_resources = resource_data
            done_callback(resource_data)
        load_resources(self.book, name, self.loaded_resources, cb)

    def goto_doc_boundary(self, start):
        name = self.book.manifest.spine[0 if start else self.book.manifest.spine.length - 1]
        self.show_name(name, initial_position={'type': 'frac', 'frac': 0 if start else 1, 'replace_history': False})

    def goto_frac(self, frac):
        if not self.book or not self.book.manifest:
            return
        chapter_start_page = 0
        total_length = self.book.manifest.spine_length
        page = total_length * frac
        chapter_frac = 0
        chapter_name = None
        for name in self.book.manifest.spine:
            chapter_length = self.book.manifest.files[name]?.length or 0
            chapter_end_page = chapter_start_page + chapter_length
            if chapter_start_page <= page <= chapter_end_page:
                num_pages = chapter_end_page - chapter_start_page - 1
                if num_pages > 0:
                    chapter_frac = (page - chapter_start_page) / num_pages
                else:
                    chapter_frac = 0
                chapter_name = name
                break
            chapter_start_page = chapter_end_page
        if not chapter_name:
            chapter_name = self.book.manifest.spine[-1]
        chapter_frac = max(0, min(chapter_frac, 1))
        if self.currently_showing.name is chapter_name:
            self.iframe_wrapper.send_message('scroll_to_frac', frac=chapter_frac)
        else:
            self.show_name(chapter_name, initial_position={'type': 'frac', 'frac': chapter_frac, 'replace_history': True})

    def goto_book_position(self, bpos):
        pages = Math.ceil(self.current_position_data.book_length / CHARS_PER_PAGE)
        val = max(0, min(float(bpos) / pages, 1))
        return self.goto_frac(val)

    def goto_pagelist_item(self, item):
        name = item.dest
        frag = item.frag or ''
        self.show_name(name, initial_position={'type': 'pagelist_ref', 'anchor': frag, 'replace_history': False})

    def on_link_to_missing_activated(self, data):
        ui_operations.show_error(_('Invalid link'), _('This link points to the file {} which does not exist in the book').format(data.name))

    def on_scroll_to_anchor(self, data):
        self.show_name(data.name, initial_position={'type': 'anchor', 'anchor': data.frag, 'replace_history': False})

    def link_in_content_popup_activated(self, name, frag, is_popup, title):
        self.content_popup_overlay.hide()
        if is_popup:
            self.iframe_wrapper.send_message('fake_popup_activation', name=name, frag=frag, title=title)
        else:
            self.goto_named_destination(name, frag)

    def goto_cfi(self, bookpos, add_to_history):
        cfiname, internal_cfi = self.parse_cfi(bookpos, self.book)
        if cfiname and internal_cfi:
            # Note that goto_cfi is used by back() so it must not add to
            # history by default, otherwise forward() will not work
            pos = {'replace_history': not add_to_history}
            name = cfiname
            pos.type, pos.cfi = 'cfi', internal_cfi
            self.show_name(name, initial_position=pos)
            return True
        return False

    def goto_reference(self, reference):
        if not self.book or not self.book.manifest:
            return
        index, refnum = reference.split('.')
        index, refnum = int(index), (int(refnum) if refnum else 1)
        chapter_name = self.book.manifest.spine[index]
        if not chapter_name:
            return False
        if self.currently_showing.name is chapter_name:
            self.iframe_wrapper.send_message('scroll_to_ref', refnum=refnum)
        else:
            self.show_name(chapter_name, initial_position={'type': 'ref', 'refnum': refnum, 'replace_history': True})
        return True

    def goto_named_destination(self, name, frag):
        if self.currently_showing.name is name:
            self.iframe_wrapper.send_message('scroll_to_anchor', frag=frag)
        else:
            spine = self.book.manifest.spine
            idx = spine.indexOf(name)
            if idx is -1:
                error_dialog(_('Destination does not exist'), _(
                    'The file {} does not exist in this book').format(name), on_close=def ():
                        ui_operations.focus_iframe()
                )
                return False
            self.show_name(name, initial_position={'type': 'anchor', 'anchor': frag, 'replace_history': False})
        return True

    def goto_toc_node(self, node_id):
        if self.overlay_prevents_navigation:
            return
        toc = self.book.manifest.toc
        found = False

        def process_node(x):
            nonlocal found
            if x.id is node_id:
                self.goto_named_destination(x.dest or '', x.frag or '')
                found = True
                return
            for c in x.children:
                process_node(c)
        if toc:
            process_node(toc)
        return found

    def sync_data_received(self, reading_pos_cfi, annotations_map):
        if annotations_map:
            ui_operations.annotations_synced(annotations_map)
            if annotations_map.highlight:
                if self.annotations_manager.merge_highlights(annotations_map.highlight):
                    hl = self.annotations_manager.highlights_for_currently_showing()
                    self.iframe_wrapper.send_message('replace_highlights', highlights=hl)
        if reading_pos_cfi:
            self.goto_cfi(reading_pos_cfi)

    def set_notes_for_highlight(self, uuid, notes):
        if self.annotations_manager.set_notes_for_highlight(uuid, notes):
            self.selection_bar.notes_edited(uuid)
            self.selection_bar.update_position()

    def add_notes_or_create_highlight(self, notes, style):
        if self.selection_bar.is_visible:
            return self.selection_bar.add_notes_or_create_highlight(notes, style)
        self.show_error(_('No selection'), _('Cannot create highlight as there is no active selection'))

    def show_next_spine_item(self, previous):
        spine = self.book.manifest.spine
        idx = spine.indexOf(self.currently_showing.name)
        if previous:
            if idx is 0:
                return False
            idx = min(spine.length - 1, max(idx - 1, 0))
            self.show_name(spine[idx], initial_position={'type': 'frac', 'frac': 1, 'replace_history': True})
        else:
            if idx is spine.length - 1:
                return False
            idx = max(0, min(spine.length - 1, idx + 1))
            self.show_name(spine[idx], initial_position={'type': 'frac', 'frac': 0, 'replace_history': True})
        return True

    def on_next_spine_item(self, data):
        self.show_next_spine_item(data.previous)

    def on_next_section(self, data):
        toc_node = get_next_section(data.forward)
        if toc_node:
            self.goto_named_destination(toc_node.dest, toc_node.frag)

    def get_current_cfi(self, request_id, callback):
        self.get_cfi_counter += 1
        request_id += ':' + self.get_cfi_counter
        self.report_cfi_callbacks[request_id] = callback
        self.iframe_wrapper.send_message('get_current_cfi', request_id=request_id)

    def update_cfi_data(self, data):
        if ui_operations.update_last_read_position:
            ui_operations.update_last_read_position(data.cfi, data.progress_frac)
        username = get_interface_data().username
        if self.book:
            self.currently_showing.bookpos = data.cfi
            unkey = username_key(username)
            if not self.book.last_read_position:
                self.book.last_read_position = {}
            self.book.last_read_position[unkey] = data.cfi
            self.set_progress_frac(data.progress_frac, data.file_progress_frac, data.page_counts)
            self.update_header_footer()
            if ui_operations.update_last_read_time:
                ui_operations.update_last_read_time(self.book)
        return username

    def on_report_cfi(self, data):
        cb = self.report_cfi_callbacks[data.request_id]
        if cb:
            cb(data.request_id.rpartition(':')[0], {
                'cfi': data.cfi,
                'progress_frac': data.progress_frac,
                'file_progress_frac': data.file_progress_frac,
                'selected_text': data.selected_text,
                'selection_bounds': data.selection_bounds,
                'page_counts': data.page_counts
            })
            v'delete self.report_cfi_callbacks[data.request_id]'

    def on_update_progress_frac(self, data):
        self.set_progress_frac(data.progress_frac, data.file_progress_frac, data.page_counts)
        self.update_header_footer()

    def on_update_cfi(self, data):
        overlay_shown = not self.processing_spine_item_display and self.overlay.is_visible
        if overlay_shown or self.search_overlay.is_visible or self.content_popup_overlay.is_visible:
            # Chrome on Android stupidly resizes the viewport when the on
            # screen keyboard is displayed. This means that the push_state()
            # below causes the overlay to be closed, making it impossible to
            # type anything into text boxes.
            # See https://bugs.chromium.org/p/chromium/issues/detail?id=404315
            return
        username = self.update_cfi_data(data)
        ui_operations.update_url_state(data.replace_history)
        if username:
            key = self.book.key
            lrd = {'device': get_device_uuid(), 'cfi': data.cfi, 'pos_frac': data.progress_frac}
            self.last_position_data = lrd
            ajax_send('book-set-last-read-position/{library_id}/{book_id}/{fmt}'.format(
                library_id=key[0], book_id=key[1], fmt=key[2]), lrd, def (end_type, xhr, ev):
                    if end_type is not 'load':
                        print('Failed to update last read position, AJAX call did not succeed')
            )
            update_book_in_recently_read_by_user_on_home_page(key[0], key[1], key[2], data.cfi)

    @property
    def current_position_data(self):
        if self.book?.manifest:
            book_length = self.book.manifest.spine_length or 0
            name = self.currently_showing.name
            chapter_length = self.book.manifest.files[name]?.length or 0
        else:
            book_length = chapter_length = 0
        pos = {
            'progress_frac': self.current_progress_frac,
            'book_length': book_length, 'chapter_length': chapter_length,
            'file_progress_frac': self.current_file_progress_frac,
            'cfi': self.currently_showing?.bookpos,
            'page_counts': self.current_page_counts,
        }
        return pos

    def show_status_message(self, msg, timeout):
        self.current_status_message = msg or ''
        self.update_header_footer()
        if self.current_status_message:
            if not timeout?:
                timeout = 10000
            window.setTimeout(def (): self.show_status_message();, timeout)

    def upload_current_position(self, callback):
        if not self.last_position_data or not self.book:
            callback(True)
            return
        key = self.book.key
        ajax_send('book-set-last-read-position/{library_id}/{book_id}/{fmt}'.format(
            library_id=key[0], book_id=key[1], fmt=key[2]),
            self.last_position_data, def (end_type, xhr, ev):
                callback(end_type is 'load')
        )

    def show_unsynced_indicator(self, visible):
        self.unsynced_message = _('Unsynced changes') if visible else ''
        self.update_header_footer()

    def create_template_renderer(self):
        if not self.book:
            return
        pos = self.current_position_data
        book_length = pos.book_length * max(0, 1 - pos.progress_frac)
        chapter_length = pos.chapter_length * max(0, 1 - pos.file_progress_frac)
        book_time = self.timers.time_for(book_length)
        chapter_time = self.timers.time_for(chapter_length)
        mi = self.book.metadata
        sd = get_session_data()
        view_mode = sd.get('read_mode')

        def render(div, name, which, override):
            return render_head_foot(
                div,
                name,
                which,
                mi,
                self.current_toc_node,
                self.current_toc_toplevel_node,
                self.current_pagelist_items,
                book_time,
                chapter_time,
                pos,
                override,
                view_mode
            )

        return render

    def update_header_footer(self):
        renderer = self.create_template_renderer()
        if not renderer:
            return
        sd = get_session_data()
        has_clock = False

        def render_template(div, edge, name):
            nonlocal has_clock
            c = div.lastChild
            b = c.previousSibling
            a = b.previousSibling
            if sd.get(f'margin_{edge}', 20) > 5:
                override = (self.current_status_message or self.unsynced_message) if edge is 'bottom' else ''
                hca = renderer(a, name, 'left', override)
                hcb = renderer(b, name, 'middle', '')
                hcc = renderer(c, name, 'right', '')
                if hca or hcb or hcc:
                    has_clock = True
            else:
                clear(a), clear(b), clear(c)

        for edge in ('left', 'right', 'top', 'bottom'):
            div = document.getElementById(f'book-{edge}-margin')
            if div:
                tname = {'left': 'left-margin', 'right': 'right-margin', 'top': 'header', 'bottom': 'footer'}[edge]
                render_template(div, edge, tname)
        if has_clock:
            if not self.timer_ids.clock:
                self.timer_ids.clock = window.setInterval(self.update_header_footer, 60000)
        else:
            if self.timer_ids.clock:
                window.clearInterval(self.timer_ids.clock)
                self.timer_ids.clock = 0

    def on_update_toc_position(self, data):
        update_visible_toc_nodes(data.visible_anchors)
        self.current_toc_families = get_current_toc_nodes()
        self.current_pagelist_items = get_current_pagelist_items()
        if self.current_toc_families.length:
            first = self.current_toc_families[0]
            self.current_toc_node = first[-1]
            self.current_toc_toplevel_node = first[0]
        else:
            self.current_toc_node = self.current_toc_toplevel_node = None
        if runtime.is_standalone_viewer:
            r = []
            for fam in self.current_toc_families:
                if fam.length:
                    r.push(fam[-1].id)
            ui_operations.update_current_toc_nodes(r)
        self.update_header_footer()

    def show_spine_item(self, resource_data):
        self.pending_load = resource_data
        if self.iframe_wrapper.ready:
            self.do_pending_load()
        else:
            self.iframe_wrapper.init()

    def show_spine_item_stage2(self, resource_data):
        # We cannot encrypt this message because the resource data contains
        # Blob objects which do not survive encryption
        self.processing_spine_item_display = True
        self.current_status_message = ''
        self.iframe.style.visibility = 'hidden'
        fdata = self.book.manifest.files[self.currently_showing.name]
        smil_map = None
        if self.book.manifest.has_smil and fdata:
            smil_map = fdata.smil_map
            self.currently_showing.smil_audio_map = get_smil_audio_map(smil_map)
        self.iframe_wrapper.send_unencrypted_message('display',
            resource_data=resource_data, book=self.book, name=self.currently_showing.name,
            initial_position=self.currently_showing.initial_position, smil_map=smil_map or None,
            settings=self.currently_showing.settings, reference_mode_enabled=self.reference_mode_enabled,
            is_titlepage=self.currently_showing.name is self.book.manifest.title_page_name,
            highlights=self.annotations_manager.highlights_for_currently_showing(),
        )

    def on_content_loaded(self, data):
        for x in self.modal_overlays:
            if not x.dont_hide_on_content_loaded:
                x.hide()
        self.processing_spine_item_display = False
        self.currently_showing.loading = False
        self.hide_loading()
        self.set_progress_frac(data.progress_frac, data.file_progress_frac, data.page_counts)
        self.update_header_footer()
        window.scrollTo(0, 0)  # ensure window is at 0 on mobile where the navbar causes issues
        if self.book_load_started:
            self.book_load_started = False
            if ui_operations.clear_history:
                ui_operations.clear_history()
        if ui_operations.content_file_changed:
            ui_operations.content_file_changed(self.currently_showing.name)
        if self.read_aloud.is_visible:
            self.read_aloud.play()
        for x in self.currently_showing.on_load:
            x()
        self.currently_showing.on_load = []

    def set_progress_frac(self, progress_frac, file_progress_frac, page_counts):
        self.current_progress_frac = progress_frac or 0
        self.current_file_progress_frac = file_progress_frac or 0
        self.current_page_counts = page_counts
        self.book_scrollbar.sync_to_contents(self.current_progress_frac)

    def update_font_size(self):
        self.iframe_wrapper.send_message('change_font_size', base_font_size=get_session_data().get('base_font_size'))

    def viewer_font_size_changed(self):
        self.iframe_wrapper.send_message('viewer_font_size_changed', base_font_size=get_session_data().get('base_font_size'))

    def update_scroll_speed(self, amt):
        self.iframe_wrapper.send_message('change_scroll_speed', lines_per_sec_auto=change_scroll_speed(amt))

    def update_color_scheme(self):
        cs = self.apply_color_scheme()
        self.iframe_wrapper.send_message('change_color_scheme', color_scheme=cs)

    def toggle_reference_mode(self):
        self.reference_mode_enabled = not self.reference_mode_enabled
        self.iframe_wrapper.send_message('set_reference_mode', enabled=self.reference_mode_enabled)
        if ui_operations.reference_mode_changed:
            ui_operations.reference_mode_changed(self.reference_mode_enabled)

    def discover_search_result(self, sr):
        if sr.search_finished:
            if self.search_result_discovery:
                self.search_result_discovery.finished = True
                if (not self.search_result_discovery.discovered
                        and self.search_result_discovery.first_search_result
                        and self.search_result_discovery.queue.length is 0):
                    sr = self.search_result_discovery.first_search_result
                    sr.force_jump_to = True
                    self.search_result_discovery.jump_forced = True
                    self.show_search_result(sr)
            return
        if sr.result_num is 1:
            self.search_result_discovery = {
                'queue': [], 'on_discovery': sr.on_discovery, 'in_flight': None, 'discovered': False,
                'first_search_result': sr, 'finished': False, 'jump_forced': False,
            }
        if not self.search_result_discovery or self.search_result_discovery.discovered or self.search_result_discovery.on_discovery is not sr.on_discovery:
            return
        self.search_result_discovery.queue.push(sr)
        if not self.search_result_discovery.in_flight:
            self.show_search_result(self.search_result_discovery.queue.shift())

    def handle_search_result_discovery(self, sr, discovered):
        if self.search_result_discovery?.on_discovery is sr.on_discovery:
            self.search_result_discovery.in_flight = None
            if discovered:
                if not self.search_result_discovery.discovered:
                    self.search_result_discovery.discovered = True
                    ui_operations.search_result_discovered(sr)
            elif not self.search_result_discovery.discovered and self.search_result_discovery.queue.length:
                self.show_search_result(self.search_result_discovery.queue.shift())
            elif not self.search_result_discovery.discovered and self.search_result_discovery.finished and not self.search_result_discovery.jump_forced:
                sr = self.search_result_discovery.first_search_result
                sr.force_jump_to = True
                self.search_result_discovery.jump_forced = True
                self.show_search_result(sr)

    def search_result_discovered(self, data):
        self.handle_search_result_discovery(data.search_result, data.discovered)

    def search_result_not_found(self, data):
        if ui_operations.search_result_not_found:
            ui_operations.search_result_not_found(data.search_result)
        self.handle_search_result_discovery(data.search_result, False)

    def show_search_result(self, sr):
        if self.currently_showing.name is sr.file_name:
            if self.currently_showing.loading:
                self.currently_showing.on_load.push(self.show_search_result.bind(None, sr))
            else:
                self.iframe_wrapper.send_message('show_search_result', search_result=sr)
        else:
            self.show_name(sr.file_name, initial_position={'type': 'search_result', 'search_result': sr, 'replace_history': True})
        if self.search_result_discovery?.on_discovery is sr.on_discovery:
            self.search_result_discovery.in_flight = sr.result_num

    def highlight_action(self, uuid, which):
        spine = self.book.manifest.spine
        spine_index = self.annotations_manager.spine_index_for_highlight(uuid, spine)
        if spine_index < 0 or spine_index >= spine.length:
            if which is 'edit':
                self.selection_bar.report_failed_edit_highlight(uuid)
            return
        if which is 'edit':
            if self.currently_showing.spine_index is spine_index:
                self.selection_bar.edit_highlight(uuid)
            else:
                self.show_name(spine[spine_index], initial_position={'type': 'edit_annotation', 'uuid': uuid, 'replace_history': True})
        elif which is 'delete':
            self.selection_bar.remove_highlight_with_id(uuid)
        elif which is 'goto':
            cfi = self.annotations_manager.cfi_for_highlight(uuid, spine_index)
            if cfi:
                self.goto_cfi(cfi, True)
