1# vim:fileencoding=utf-8
2# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
3
4# Notes of paged mode scrolling:
5#  All the math in paged mode is based on the block and inline directions.
6#  Inline is "the direction lines of text go."
7#  In horizontal scripts such as English and Hebrew, the inline is horizontal
8#  and the block is vertical.
9#  In vertical languages such as Japanese and Mongolian, the inline is vertical
10#  and block is horizontal.
11#  Regardless of language, paged mode scrolls by column in the inline direction,
12#  because by the CSS spec, columns are laid out in the inline direction.
13#
14#  In horizontal RTL books, such as Hebrew, the inline direction goes right to left.
15#  |<------|
16#  This means that the column positions become negative as you scroll.
17#  This is hidden from paged mode by the viewport, which transparently
18#  negates any inline coordinates sent in to the viewport_to_document* functions
19#  and the various scroll_to/scroll_by functions, as well as the reported X position.
20#
21#  The result of all this is that paged mode's math can safely pretend that
22#  things scroll in the positive inline direction.
23
24from __python__ import hash_literals, bound_methods
25
26import traceback
27from elementmaker import E
28
29from dom import set_css
30from read_book.cfi import (
31    at_current as cfi_at_current, at_point as cfi_at_point,
32    scroll_to as cfi_scroll_to
33)
34from read_book.globals import current_spine_item, get_boss, rtl_page_progression
35from read_book.settings import opts
36from read_book.viewport import scroll_viewport, line_height, rem_size
37from utils import (
38    get_elem_data, set_elem_data
39)
40
41
42def first_child(parent):
43    c = parent.firstChild
44    count = 0
45    while c?.nodeType is not Node.ELEMENT_NODE and count < 20:
46        c = c?.nextSibling
47        count += 1
48    if c?.nodeType is Node.ELEMENT_NODE:
49        return c
50
51def has_start_text(elem):
52    # Returns true if elem has some non-whitespace text before its first child
53    # element
54    for c in elem.childNodes:
55        if c.nodeType is not Node.TEXT_NODE:
56            break
57        if c.nodeType is Node.TEXT_NODE and c.nodeValue and /\S/.test(c.nodeValue):
58            return True
59    return False
60
61def handle_rtl_body(body_style):
62    if body_style.direction is "rtl":
63        # If this in not set, Chrome scrolling breaks for some RTL and vertical content.
64        document.documentElement.style.overflow = 'visible'
65
66def create_page_div(elem):
67    div = E('blank-page-div', ' \n    ')
68    document.body.appendChild(div)
69    set_css(div, break_before='always', display='block', white_space='pre', background_color='transparent',
70            background_image='none', border_width='0', float='none', position='static')
71
72_in_paged_mode = False
73def in_paged_mode():
74    return _in_paged_mode
75
76
77col_size = screen_inline = screen_block = cols_per_screen = gap = col_and_gap = number_of_cols = last_scrolled_to_column = 0
78is_full_screen_layout = False
79
80
81def reset_paged_mode_globals():
82    nonlocal _in_paged_mode, col_size, col_and_gap, screen_block, gap, screen_inline, is_full_screen_layout, cols_per_screen, number_of_cols, last_scrolled_to_column
83    scroll_viewport.reset_globals()
84    col_size = screen_inline = screen_block = cols_per_screen = gap = col_and_gap = number_of_cols = last_scrolled_to_column = 0
85    is_full_screen_layout = _in_paged_mode = False
86    resize_manager.reset()
87
88def column_at(pos):
89    # Return the (zero-based) number of the column that contains pos
90    si = scroll_viewport.paged_content_inline_size()
91    if pos >= si - col_and_gap:
92        pos = si - col_size + 10
93    # we subtract 1 here so that a point at the absolute trailing (right in
94    # horz-LTR) edge of a column remains in the column and not at the next column
95    return max(0, (pos + gap - 1)) // col_and_gap
96
97def fit_images():
98    # Ensure no images are wider than the available size of a column. Note
99    # that this method use getBoundingClientRect() which means it will
100    # force a relayout if the render tree is dirty.
101    inline_limited_images = v'[]'
102    block_limited_images = v'[]'
103    img_tags = document.getElementsByTagName('img')
104    bounding_rects = v'[]'
105    for img_tag in img_tags:
106        bounding_rects.push(img_tag.getBoundingClientRect())
107    maxb = screen_block
108    for i in range(img_tags.length):
109        img = img_tags[i]
110        br = bounding_rects[i]
111        previously_limited = get_elem_data(img, 'inline-limited', False)
112        data = get_elem_data(img, 'img-data', None)
113        if data is None:
114            data = {'left':br.left, 'right':br.right, 'height':br.height, 'display': img.style.display}
115            set_elem_data(img, 'img-data', data)
116
117        # Get start of image bounding box in the column direction (inline)
118        image_start = scroll_viewport.viewport_to_document_inline(scroll_viewport.rect_inline_start(br), img.ownerDocument)
119        col_start = column_at(image_start) * col_and_gap
120        # Get inline distance from the start of the column to the start of the image bounding box
121        column_start_to_image_start = image_start - col_start
122        image_block_size = scroll_viewport.rect_block_size(br)
123        image_inline_size = scroll_viewport.rect_inline_size(br)
124        # Get the inline distance from the start of the column to the end of the image
125        image_inline_end = column_start_to_image_start + image_inline_size
126        # If the end of the image goes past the column, add it to the list of inline_limited_images
127        if previously_limited or image_inline_end > col_size:
128            inline_limited_images.push(v'[img, col_size - column_start_to_image_start]')
129        previously_limited = get_elem_data(img, 'block-limited', False)
130        if previously_limited or image_block_size > maxb or (image_block_size is maxb and image_inline_size > col_size):
131            block_limited_images.push(img)
132        if previously_limited:
133            set_css(img, break_before='auto', display=data.display)
134        set_css(img, break_inside='avoid')
135
136    for img_tag, max_inline_size in inline_limited_images:
137        if scroll_viewport.vertical_writing_mode:
138            img_tag.style.setProperty('max-height', max_inline_size+'px')
139        else:
140            img_tag.style.setProperty('max-width', max_inline_size+'px')
141        set_elem_data(img_tag, 'inline-limited', True)
142
143    for img_tag in block_limited_images:
144        if scroll_viewport.vertical_writing_mode:
145            set_css(img_tag, break_before='always', max_width='100vw')
146        else:
147            set_css(img_tag, break_before='always', max_height='100vh')
148        set_elem_data(img_tag, 'block-limited', True)
149
150
151def cps_by_em_size():
152    ans = cps_by_em_size.ans
153    fs = window.getComputedStyle(document.body).fontSize
154    if not ans or cps_by_em_size.at_font_size is not fs:
155        d = document.createElement('span')
156        d.style.position = 'absolute'
157        d.style.visibility = 'hidden'
158        d.style.width = '1rem'
159        d.style.fontSize = '1rem'
160        d.style.paddingTop = d.style.paddingBottom = d.style.paddingLeft = d.style.paddingRight = '0'
161        d.style.marginTop = d.style.marginBottom = d.style.marginLeft = d.style.marginRight = '0'
162        d.style.borderStyle = 'none'
163        document.body.appendChild(d)
164        w = d.clientWidth
165        document.body.removeChild(d)
166        ans = cps_by_em_size.ans = max(2, w)
167        cps_by_em_size.at_font_size = fs
168    return ans
169
170
171def calc_columns_per_screen():
172    cps = opts.columns_per_screen or {}
173    cps = cps.landscape if scroll_viewport.width() > scroll_viewport.height() else cps.portrait
174    try:
175        cps = int(cps)
176    except:
177        cps = 0
178    if not cps:
179        cps = int(Math.floor(scroll_viewport.inline_size() / (35 * cps_by_em_size())))
180    cps = max(1, min(cps or 1, 20))
181    return cps
182
183
184def get_columns_per_screen_data():
185    which = 'landscape' if scroll_viewport.width() > scroll_viewport.height() else 'portrait'
186    return {'which': which, 'cps': calc_columns_per_screen()}
187
188
189def will_columns_per_screen_change():
190    return calc_columns_per_screen() != cols_per_screen
191
192
193class ScrollResizeBugWatcher:
194
195    # In Chrome sometimes after layout the scrollWidth of body increases after a
196    # few milliseconds, this can cause scrolling to the end to not work
197    # immediately after layout. This happens without a resize event, and
198    # without triggering the ResizeObserver and only in paged mode.
199
200    def __init__(self):
201        self.max_time = 750
202        self.last_layout_at = 0
203        self.last_command = None
204        self.doc_size = 0
205        self.timer = None
206
207    def layout_done(self):
208        self.last_layout_at = window.performance.now()
209        self.last_command = None
210        self.cancel_timer()
211
212    def scrolled(self, pos, limit):
213        self.cancel_timer()
214        now = window.performance.now()
215        if now - self.last_layout_at < self.max_time and self.last_command is not None:
216            self.doc_size = scroll_viewport.paged_content_inline_size()
217            self.check_for_resize_bug()
218
219    def cancel_timer(self):
220        if self.timer is not None:
221            window.clearTimeout(self.timer)
222            self.timer = None
223
224    def check_for_resize_bug(self):
225        sz = scroll_viewport.paged_content_inline_size()
226        if sz != self.doc_size:
227            return self.redo_scroll()
228        now = window.performance.now()
229        if now - self.last_layout_at < self.max_time:
230            window.setTimeout(self.check_for_resize_bug, 10)
231        else:
232            self.timer = None
233
234    def redo_scroll(self):
235        if self.last_command:
236            self.last_command()
237            self.last_command = None
238        self.timer = None
239        self.doc_size = 0
240
241
242scroll_resize_bug_watcher = ScrollResizeBugWatcher()
243
244def layout(is_single_page, on_resize):
245    nonlocal _in_paged_mode, col_size, col_and_gap, screen_block, gap, screen_inline, is_full_screen_layout, cols_per_screen, number_of_cols
246    line_height(True)
247    rem_size(True)
248    body_style = window.getComputedStyle(document.body)
249    scroll_viewport.initialize_on_layout(body_style)
250    first_layout = not _in_paged_mode
251    cps = calc_columns_per_screen()
252    if first_layout:
253        handle_rtl_body(body_style)
254        # Check if the current document is a full screen layout like
255        # cover, if so we treat it specially.
256        single_screen = scroll_viewport.document_block_size() < (scroll_viewport.block_size() + 75)
257        first_layout = True
258        svgs = document.getElementsByTagName('svg')
259        has_svg = svgs.length > 0
260        imgs = document.getElementsByTagName('img')
261        only_img = imgs.length is 1 and document.getElementsByTagName('div').length < 3 and document.getElementsByTagName('p').length < 2
262        if only_img and window.getComputedStyle(imgs[0]).zIndex < 0:
263            # Needed for some stupidly coded fixed layout EPUB comics, see for
264            # instance: https://bugs.launchpad.net/calibre/+bug/1667357
265            imgs[0].style.zIndex = '0'
266        if not single_screen and cps > 1:
267            num = cps - 1
268            elems = document.querySelectorAll('body > *')
269            if elems.length is 1:
270                # Workaround for the case when the content is wrapped in a
271                # 100% height <div>. This causes the generated page divs to
272                # not be in the correct location, at least in WebKit. See
273                # https://bugs.launchpad.net/bugs/1594657 for an example.
274                elems[0].style.height = 'auto'
275            while num > 0:
276                num -= 1
277                create_page_div()
278
279    n = cols_per_screen = cps
280    # Calculate the column size so that cols_per_screen columns fit exactly in
281    # the window inline dimension, with their separator margins
282    wi = col_size = screen_inline = scroll_viewport.inline_size()
283    margin_size = (opts.margin_left + opts.margin_right) if scroll_viewport.horizontal_writing_mode else (opts.margin_top + opts.margin_bottom)
284    # a zero margin causes scrolling issues, see https://bugs.launchpad.net/calibre/+bug/1918437
285    margin_size = max(1, margin_size)
286    gap = margin_size
287    if n > 1:
288        # Adjust the margin so that the window inline dimension satisfies
289        # col_size * n + (n-1) * 2 * margin = window_inline
290        gap += ((wi + margin_size) % n)  # Ensure wi + gap is a multiple of n
291        col_size = ((wi + gap) // n) - gap
292
293    screen_block = scroll_viewport.block_size()
294    col_and_gap = col_size + gap
295
296    set_css(document.body, column_gap=gap + 'px', column_width=col_size + 'px', column_rule='0px inset blue',
297        min_width='0', max_width='none', min_height='0', max_height='100vh', column_fill='auto',
298        margin='0', border_width='0', padding='0', box_sizing='content-box',
299        width=scroll_viewport.width() + 'px', height=scroll_viewport.height() + 'px', overflow_wrap='break-word'
300    )
301    # Without this, webkit bleeds the margin of the first block(s) of body
302    # above the columns, which causes them to effectively be added to the
303    # page margins (the margin collapse algorithm)
304    document.body.style.setProperty('-webkit-margin-collapse', 'separate')
305    c = first_child(document.body)
306    if c:
307        # Remove page breaks on the first few elements to prevent blank pages
308        # at the start of a chapter
309        set_css(c, break_before='avoid')
310        if c.tagName.toLowerCase() is 'div':
311            c2 = first_child(c)
312            if c2 and not has_start_text(c):
313                # Common pattern of all content being enclosed in a wrapper
314                # <div>, see for example: https://bugs.launchpad.net/bugs/1366074
315                # In this case, we also modify the first child of the div
316                # as long as there was no text before it.
317                set_css(c2, break_before='avoid')
318
319    if first_layout:
320        # Because of a bug in webkit column mode, svg elements defined with
321        # width 100% are wider than body and lead to a blank page after the
322        # current page (when cols_per_screen == 1). Similarly img elements
323        # with height=100% overflow the first column
324        is_full_screen_layout = is_single_page
325        if not is_full_screen_layout:
326            has_no_more_than_two_columns = (scroll_viewport.paged_content_inline_size() < 2*wi + 10)
327            if has_no_more_than_two_columns and single_screen:
328                if only_img and imgs.length and imgs[0].getBoundingClientRect().left < wi:
329                    is_full_screen_layout = True
330                if has_svg and svgs.length == 1 and svgs[0].getBoundingClientRect().left < wi:
331                    is_full_screen_layout = True
332        if is_full_screen_layout and only_img and cols_per_screen > 1:
333            cols_per_screen = 1
334            col_size = screen_inline
335            col_and_gap = col_size + gap
336            number_of_cols = 1
337            document.body.style.columnWidth = f'100vw'
338
339    # Some browser engine, WebKit at least, adjust column sizes to please
340    # themselves, unless the container size is an exact multiple, so we check
341    # for that and manually set the container sizes.
342    def check_column_sizes():
343        nonlocal number_of_cols
344        ncols = number_of_cols = (scroll_viewport.paged_content_inline_size() + gap) / col_and_gap
345        if ncols is not Math.floor(ncols):
346            data = {'col_size':col_size, 'gap':gap, 'scrollWidth':scroll_viewport.paged_content_inline_size(), 'ncols':ncols, 'desired_inline_size':dis}
347            return data
348
349    data = check_column_sizes()
350    if data:
351        dis = data.desired_inline_size
352        for elem in document.documentElement, document.body:
353            set_css(elem, max_width=dis + 'px', min_width=dis + 'px')
354            if scroll_viewport.vertical_writing_mode:
355                set_css(elem, max_height=dis + 'px', min_height=dis + 'px')
356    data = check_column_sizes()
357    if data:
358        print('WARNING: column layout broken, probably because there is some non-reflowable content in the book whose inline size is greater than the column size', data)
359
360    _in_paged_mode = True
361    fit_images()
362    scroll_resize_bug_watcher.layout_done()
363    return gap
364
365def current_scroll_offset():
366    return scroll_viewport.inline_pos()
367
368
369def scroll_to_offset(offset):
370    scroll_viewport.scroll_to_in_inline_direction(offset)
371
372
373def scroll_to_column(number, notify=False, duration=1000):
374    nonlocal last_scrolled_to_column
375    last_scrolled_to_column = number
376    pos = number * col_and_gap
377    limit = scroll_viewport.paged_content_inline_size() - scroll_viewport.inline_size()
378    pos = min(pos, limit)
379    scroll_to_offset(pos)
380    scroll_resize_bug_watcher.scrolled(pos, limit)
381
382
383def scroll_to_pos(pos, notify=False, duration=1000):
384    nonlocal last_scrolled_to_column
385    # Scroll to the column containing pos
386    if jstype(pos) is not 'number':
387        print(pos, 'is not a number, cannot scroll to it!')
388        return
389    if is_full_screen_layout:
390        scroll_to_offset(0)
391        last_scrolled_to_column = 0
392        return
393    scroll_to_column(column_at(pos), notify=notify, duration=duration)
394
395
396def scroll_to_previous_position(fsd):
397    fsd = fsd or next_spine_item.forward_scroll_data
398    next_spine_item.forward_scroll_data = None
399    if 0 < fsd.cols_left < cols_per_screen and cols_per_screen < number_of_cols:
400        scroll_resize_bug_watcher.last_command = scroll_to_previous_position.bind(None, fsd)
401        scroll_to_column(fsd.current_col)
402        return True
403
404
405def scroll_to_fraction(frac, on_initial_load):
406    # Scroll to the position represented by frac (number between 0 and 1)
407    if on_initial_load and frac is 1 and is_return() and scroll_to_previous_position():
408        return
409    scroll_resize_bug_watcher.last_command = scroll_to_fraction.bind(None, frac, False)
410    pos = Math.floor(scroll_viewport.paged_content_inline_size() * frac)
411    scroll_to_pos(pos)
412
413
414def column_boundaries():
415    # Return the column numbers at the left edge and after the right edge
416    # of the viewport
417    l = column_at(current_scroll_offset() + 10)
418    return l, l + cols_per_screen
419
420
421def column_at_current_scroll_offset():
422    return column_at(current_scroll_offset() + 10)
423
424
425def current_column_location():
426    # The location of the starting edge of the first column currently
427    # visible in the viewport
428    if is_full_screen_layout:
429        return 0
430    return column_at_current_scroll_offset() * col_and_gap
431
432
433def number_of_cols_left():
434    current_col = column_at(current_scroll_offset() + 10)
435    cols_left = number_of_cols - (current_col + cols_per_screen)
436    return Math.max(0, cols_left)
437
438
439def next_screen_location():
440    # The position to scroll to for the next screen (which could contain
441    # more than one pages). Returns -1 if no further scrolling is possible.
442    if is_full_screen_layout:
443        return -1
444    cc = current_column_location()
445    ans = cc + screen_inline + 1
446    if cols_per_screen > 1 and 0 < number_of_cols_left() < cols_per_screen:
447        return -1  # Only blank, dummy pages left
448    limit = scroll_viewport.paged_content_inline_size() - scroll_viewport.inline_size()
449    if limit < col_and_gap:
450        return -1
451    if ans > limit:
452        ans = limit if Math.ceil(current_scroll_offset()) < limit else -1
453    return ans
454
455
456def previous_screen_location():
457    # The position to scroll to for the previous screen (which could contain
458    # more than one pages). Returns -1 if no further scrolling is possible.
459    if is_full_screen_layout:
460        return -1
461    cc = current_column_location()
462    ans = cc - cols_per_screen * col_and_gap
463    if ans < 0:
464        # We ignore small scrolls (less than 15px) when going to previous
465        # screen
466        ans = 0 if current_scroll_offset() > 15 else -1
467    return ans
468
469
470def next_col_location():
471    # The position to scroll to for the next column (same as
472    # next_screen_location() if columns per screen == 1). Returns -1 if no
473    # further scrolling is possible.
474    if is_full_screen_layout:
475        return -1
476    cc = current_column_location()
477    ans = cc + col_and_gap
478    limit = scroll_viewport.paged_content_inline_size() - scroll_viewport.inline_size()
479    # print(f'cc={cc} col_and_gap={col_and_gap} ans={ans} limit={limit} content_inline_size={scroll_viewport.paged_content_inline_size()} inline={scroll_viewport.inline_size()} current_scroll_offset={current_scroll_offset()}')
480    if ans > limit:
481        if Math.ceil(current_scroll_offset()) < limit and column_at(limit) > column_at_current_scroll_offset():
482            ans = limit
483        else:
484            ans = -1
485    return ans
486
487
488def previous_col_location():
489    # The position to scroll to for the previous column (same as
490    # previous_screen_location() if columns per screen == 1). Returns -1 if
491    # no further scrolling is possible.
492    if is_full_screen_layout:
493        return -1
494    cc = current_column_location()
495    ans = cc - col_and_gap
496    if ans < 0:
497        if Math.floor(current_scroll_offset()) > 0 and column_at(0) < column_at_current_scroll_offset():
498            ans = 0
499        else:
500            ans = -1
501    return ans
502
503
504def jump_to_anchor(name):
505    # Jump to the element identified by anchor name.
506    elem = document.getElementById(name)
507    if not elem:
508        elems = document.getElementsByName(name)
509        if elems:
510            elem = elems[0]
511    if not elem:
512        return
513    scroll_to_elem(elem)
514
515
516def scrollable_element(elem):
517    # bounding rect calculation for an inline element containing a block
518    # element that spans multiple columns is incorrect. Detet the common case
519    # of this and avoid it. See https://bugs.launchpad.net/calibre/+bug/1918437
520    # for a test case.
521    if not in_paged_mode() or window.getComputedStyle(elem).display.indexOf('inline') < 0 or not elem.firstElementChild:
522        return elem
523    if window.getComputedStyle(elem.firstElementChild).display.indexOf('block') > -1 and elem.getBoundingClientRect().top < -100:
524        return elem.firstElementChild
525    return elem
526
527
528def scroll_to_elem(elem):
529    elem = scrollable_element(elem)
530    scroll_viewport.scroll_into_view(elem)
531
532    if in_paged_mode():
533        # Ensure we are scrolled to the column containing elem
534
535        # Because of a bug in WebKit's getBoundingClientRect() in column
536        # mode, this position can be inaccurate, see
537        # https://bugs.launchpad.net/calibre/+bug/1132641 for a test case.
538        # The usual symptom of the inaccuracy is br.top is highly negative.
539        br = elem.getBoundingClientRect()
540        if br.top < -100:
541            # This only works because of the preceding call to
542            # elem.scrollIntoView(). However, in some cases it gives
543            # inaccurate results, so we prefer the bounding client rect,
544            # when possible.
545
546            # In horizontal writing, the inline start position depends on the direction
547            if scroll_viewport.horizontal_writing_mode:
548                inline_start = elem.scrollLeft if scroll_viewport.ltr else elem.scrollRight
549            # In vertical writing, the inline start position is always the top since
550            # vertical text only flows top-to-bottom
551            else:
552                inline_start = elem.scrollTop
553        else:
554            # If we can use the rect, just use the simpler viewport helper function
555            inline_start = scroll_viewport.rect_inline_start(br)
556
557        scroll_to_pos(scroll_viewport.viewport_to_document_inline(inline_start+2, elem.ownerDocument))
558
559def snap_to_selection():
560    # Ensure that the viewport is positioned at the start of the column
561    # containing the start of the current selection
562    if in_paged_mode():
563        sel = window.getSelection()
564        r = sel.getRangeAt(0).getBoundingClientRect()
565        node = sel.anchorNode
566        # Columns are in the inline direction, so get the beginning of the element in the inline
567        pos = scroll_viewport.viewport_to_document_inline(
568            scroll_viewport.rect_inline_start(r), doc=node.ownerDocument)
569
570        # Ensure we are scrolled to the column containing the start of the
571        # selection
572        scroll_to_pos(pos+5)
573
574
575def ensure_selection_boundary_visible(use_end):
576    sel = window.getSelection()
577    try:
578        rr = sel.getRangeAt(0)
579    except:
580        rr = None
581    if rr:
582        r = rr.getBoundingClientRect()
583        if r:
584            cnum = column_at_current_scroll_offset()
585            scroll_to_column(cnum)
586            node = sel.focusNode if use_end else sel.anchorNode
587            # Columns are in the inline direction, so get the beginning of the element in the inline
588            x = scroll_viewport.rect_inline_end(r) if use_end else scroll_viewport.rect_inline_start(r)
589            if x < 0 or x >= scroll_viewport.inline_size():
590                pos = scroll_viewport.viewport_to_document_inline(x, doc=node.ownerDocument)
591                scroll_to_pos(pos+5)
592
593
594def jump_to_cfi(cfi):
595    # Jump to the position indicated by the specified conformal fragment
596    # indicator.
597    scroll_resize_bug_watcher.last_command = jump_to_cfi.bind(None, cfi)
598    cfi_scroll_to(cfi, def(x, y):
599        if scroll_viewport.horizontal_writing_mode:
600            scroll_to_pos(x)
601        else:
602            scroll_to_pos(y)
603    )
604
605def current_cfi():
606    # The Conformal Fragment Identifier at the current position, returns
607    # null if it could not be calculated.
608    ans = None
609    if in_paged_mode():
610        for cnum in range(cols_per_screen):
611            left = cnum * (col_and_gap + gap)
612            right = left + col_size
613            top, bottom = 0, scroll_viewport.height()
614            midx = (right - left) // 2
615            deltax = (right - left) // 24
616            deltay = (bottom - top) // 24
617            midy = (bottom - top) // 2
618            yidx = 0
619            while True:
620                yb, ya = midy - yidx * deltay, midy + yidx * deltay
621                if yb <= top or ya >= bottom:
622                    break
623                yidx += 1
624                xidx = 0
625                ys = v'[ya]' if ya is yb else v'[yb, ya]'
626                for cury in ys:
627                    xb, xa = midx - xidx * deltax, midx + xidx * deltax
628                    if xa <= left or xb >= right:
629                        break
630                    xidx += 1
631                    xs = v'[xa]' if xa is xb else v'[xb, xa]'
632                    for curx in xs:
633                        cfi = cfi_at_point(curx, cury)
634                        if cfi:
635                            # print('Viewport cfi:', cfi)
636                            return cfi
637    else:
638        try:
639            ans = cfi_at_current() or None
640        except:
641            traceback.print_exc()
642    # if ans:
643    #     print('Viewport cfi:', ans)
644    return ans
645
646
647def progress_frac(frac):
648    # The current scroll position as a fraction between 0 and 1
649    if in_paged_mode():
650        limit = scroll_viewport.paged_content_inline_size() - scroll_viewport.inline_size()
651        if limit <= 0:
652            return 1  # ensures that if the book ends with a single page file the last shown percentage is 100%
653        return current_scroll_offset() / limit
654    # In flow mode, we scroll in the block direction, so use that
655    limit = scroll_viewport.document_block_size() - scroll_viewport.block_size()
656    if limit <= 0:
657        return 1
658    return Math.max(0, Math.min(scroll_viewport.block_pos() / limit, 1))
659
660
661def page_counts():
662    if in_paged_mode():
663        return {'current': column_at_current_scroll_offset(), 'total': number_of_cols, 'pages_per_screen': cols_per_screen}
664    doc_size = scroll_viewport.document_block_size()
665    screen_size = scroll_viewport.block_size()
666    pos = scroll_viewport.block_pos()
667    return {
668        'current': (pos + 10) // screen_size,
669        'total': doc_size // screen_size,
670        'pages_per_screen': 1
671    }
672
673
674def next_spine_item(backward):
675    if not backward:
676        csi = current_spine_item()
677        next_spine_item.forward_scroll_data = {
678            'cols_per_screen': cols_per_screen, 'cols_left': number_of_cols_left(),
679            'spine_index': csi.index, 'spine_name': csi.name, 'current_col': column_at(current_scroll_offset() + 10)
680        }
681    get_boss().send_message('next_spine_item', previous=backward)
682
683
684def is_return():
685    fsd = next_spine_item.forward_scroll_data
686    csi = current_spine_item()
687    return fsd and fsd.cols_per_screen is cols_per_screen and fsd.spine_index is csi.index and fsd.spine_name is csi.name
688
689
690class HandleWheel:
691
692    def __init__(self):
693        self.reset()
694
695    def reset(self):
696        self.last_event_mode = 'page'
697        self.last_event_at = -10000
698        self.last_event_backwards = False
699        self.accumulated_scroll = 0
700
701    def onwheel(self, evt):
702        if not evt.deltaY:
703            return
704        backward = evt.deltaY < 0
705        if evt.deltaMode is window.WheelEvent.DOM_DELTA_PIXEL:
706            self.add_pixel_scroll(backward, Math.abs(evt.deltaY))
707        else:
708            self.do_scroll(backward)
709
710    def add_pixel_scroll(self, backward, deltaY):
711        now = window.performance.now()
712        if now - self.last_event_at > 1000 or self.last_event_backwards is not backward or self.last_event_mode is not 'pixel':
713            self.accumulated_scroll = 0
714        self.last_event_mode = 'pixel'
715        self.last_event_at = now
716        self.last_event_backwards = backward
717        self.accumulated_scroll += deltaY
718        if self.accumulated_scroll > opts.paged_pixel_scroll_threshold:
719            self.do_scroll(backward)
720
721    def do_scroll(self, backward):
722        self.reset()
723        if opts.paged_wheel_scrolls_by_screen:
724            pos = previous_screen_location() if backward else next_screen_location()
725        else:
726            pos = previous_col_location() if backward else next_col_location()
727        if pos is -1:
728            next_spine_item(backward)
729        else:
730            scroll_to_pos(pos)
731
732
733wheel_handler = HandleWheel()
734onwheel = wheel_handler.onwheel
735
736
737def scroll_by_page(backward, by_screen, flip_if_rtl_page_progression):
738    if flip_if_rtl_page_progression and rtl_page_progression():
739        backward = not backward
740
741    if by_screen:
742        pos = previous_screen_location() if backward else next_screen_location()
743        pages = cols_per_screen
744    else:
745        pos = previous_col_location() if backward else next_col_location()
746        pages = 1
747    if pos is -1:
748        # dont report human scroll since we dont know if a full page was
749        # scrolled or not
750        next_spine_item(backward)
751    else:
752        if not backward:
753            scrolled_frac = (pages / number_of_cols) if number_of_cols > 0 else 0
754            get_boss().report_human_scroll(scrolled_frac)
755        else:
756            get_boss().report_human_scroll()
757        scroll_to_pos(pos)
758
759
760def scroll_to_extend_annotation(backward):
761    pos = previous_col_location() if backward else next_col_location()
762    if pos is -1:
763        return False
764    scroll_to_pos(pos)
765    return True
766
767
768def handle_shortcut(sc_name, evt):
769    if sc_name is 'up':
770        scroll_by_page(backward=True, by_screen=True, flip_if_rtl_page_progression=False)
771        return True
772    if sc_name is 'down':
773        scroll_by_page(backward=False, by_screen=True, flip_if_rtl_page_progression=False)
774        return True
775    if sc_name is 'start_of_file':
776        get_boss().report_human_scroll()
777        scroll_to_offset(0)
778        return True
779    if sc_name is 'end_of_file':
780        get_boss().report_human_scroll()
781        scroll_to_offset(scroll_viewport.document_inline_size())
782        return True
783    if sc_name is 'left':
784        scroll_by_page(backward=True, by_screen=False, flip_if_rtl_page_progression=True)
785        return True
786    if sc_name is 'right':
787        scroll_by_page(backward=False, by_screen=False, flip_if_rtl_page_progression=True)
788        return True
789    if sc_name is 'start_of_book':
790        get_boss().report_human_scroll()
791        get_boss().send_message('goto_doc_boundary', start=True)
792        return True
793    if sc_name is 'end_of_book':
794        get_boss().report_human_scroll()
795        get_boss().send_message('goto_doc_boundary', start=False)
796        return True
797    if sc_name is 'pageup':
798        scroll_by_page(backward=True, by_screen=True, flip_if_rtl_page_progression=False)
799        return True
800    if sc_name is 'pagedown':
801        scroll_by_page(backward=False, by_screen=True, flip_if_rtl_page_progression=False)
802        return True
803    if sc_name is 'toggle_autoscroll':
804        auto_scroll_action('toggle')
805        return True
806    return False
807
808
809def handle_gesture(gesture):
810    if gesture.type is 'swipe':
811        if gesture.axis is 'vertical':
812            if not gesture.active:
813                get_boss().send_message('next_section', forward=gesture.direction is 'up')
814        else:
815            if not gesture.active or gesture.is_held:
816                scroll_by_page(gesture.direction is 'right', True, flip_if_rtl_page_progression=True)
817    # Gesture progression direction is determined in the gesture code,
818    # don't set flip_if_rtl_page_progression=True here.
819    elif gesture.type is 'prev-page':
820        scroll_by_page(True, opts.paged_taps_scroll_by_screen, flip_if_rtl_page_progression=False)
821    elif gesture.type is 'next-page':
822        scroll_by_page(False, opts.paged_taps_scroll_by_screen, flip_if_rtl_page_progression=False)
823
824
825anchor_funcs = {
826    'pos_for_elem': def pos_for_elem(elem):
827        if not elem:
828            return 0
829        elem = scrollable_element(elem)
830        br = elem.getBoundingClientRect()
831        pos = scroll_viewport.viewport_to_document_inline(
832            scroll_viewport.rect_inline_start(br))
833        return column_at(pos)
834    ,
835    'visibility': def visibility(pos):
836        first = column_at(current_scroll_offset() + 10)
837        if pos < first:
838            return -1
839        if pos < first + cols_per_screen:
840            return 0
841        return 1
842    ,
843    'cmp': def cmp(a, b):
844        return a - b
845    ,
846}
847
848
849class ResizeManager:
850
851    def __init__(self):
852        self.reset()
853
854    def reset(self):
855        self.resize_in_progress = None
856        self.last_transition = None
857
858    def start_resize(self, width, height):
859        self.resize_in_progress = {'width': width, 'height': height, 'column': last_scrolled_to_column}
860
861    def end_resize(self):
862        if not self.resize_in_progress:
863            return
864        rp, self.resize_in_progress = self.resize_in_progress, None
865        transition = {'before': rp, 'after': {
866            'width': scroll_viewport.width(), 'height': scroll_viewport.height(), 'column': last_scrolled_to_column}}
867        if self.is_inverse_transition(transition):
868            if transition.after.column is not self.last_transition.before.column:
869                scroll_to_column(transition.after.column)
870                transition.after.column = last_scrolled_to_column
871        self.last_transition = transition
872
873    def is_inverse_transition(self, transition):
874        p = self.last_transition
875        if not p:
876            return False
877        p = p.after
878        n = transition.before
879        return p.column is n.column and p.width is n.width and p.height is n.height
880
881
882resize_manager = ResizeManager()
883def prepare_for_resize(width, height):
884    resize_manager.start_resize(width, height)
885
886def resize_done():
887    resize_manager.end_resize()
888
889
890def auto_scroll_action(action):
891    if action is 'toggle':
892        get_boss().send_message('error', errkey='no-auto-scroll-in-paged-mode', is_non_critical=True)
893    return False
894
895
896class DragScroller:
897
898    INTERVAL = 500
899
900    def __init__(self):
901        self.backward = False
902        self.timer_id = None
903
904    def is_running(self):
905        return self.timer_id is not None
906
907    def start(self, backward):
908        if not self.is_running() or backward is not self.backward:
909            self.stop()
910            self.backward = backward
911            self.timer_id = window.setTimeout(self.do_one_page_turn, self.INTERVAL)
912
913    def do_one_page_turn(self):
914        pos = previous_col_location() if self.backward else next_col_location()
915        if pos >= 0:
916            scroll_to_pos(pos)
917            self.timer_id = window.setTimeout(self.do_one_page_turn, self.INTERVAL * 2)
918        else:
919            self.stop()
920
921    def stop(self):
922        if self.timer_id is not None:
923            window.clearTimeout(self.timer_id)
924            self.timer_id = None
925
926
927drag_scroller = DragScroller()
928
929
930def cancel_drag_scroll():
931    drag_scroller.stop()
932
933
934def start_drag_scroll(delta):
935    drag_scroller.start(delta < 0)
936