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