1# vim:fileencoding=utf-8 2# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net> 3from __python__ import bound_methods, hash_literals 4 5# Notes about flow mode scrolling: 6# All the math in flow mode is based on the block and inline directions. 7# Inline is "the direction lines of text go." 8# In horizontal scripts such as English and Hebrew, the inline is horizontal 9# and the block is vertical. 10# In vertical languages such as Japanese and Mongolian, the inline is vertical 11# and block is horizontal. 12# Regardless of language, flow mode scrolls in the block direction. 13# 14# In vertical RTL books, the block position scrolls from right to left. |<------| 15# This means that the viewport positions become negative as you scroll. 16# This is hidden from flow mode by the viewport, which transparently 17# negates any inline coordinates sent in to the viewport_to_document* functions 18# and the various scroll_to/scroll_by functions, as well as the reported X position. 19# 20# The result of all this is that flow mode's math can safely pretend that 21# things scroll in the positive block direction. 22 23from dom import set_css 24from range_utils import wrap_range, unwrap 25from read_book.cfi import scroll_to as cfi_scroll_to 26from read_book.globals import current_spine_item, get_boss, rtl_page_progression, ltr_page_progression 27from read_book.settings import opts 28from read_book.viewport import line_height, rem_size, scroll_viewport 29 30 31def flow_to_scroll_fraction(frac, on_initial_load): 32 scroll_viewport.scroll_to_in_block_direction(scroll_viewport.document_block_size() * frac) 33 34 35small_scroll_events = v'[]' 36 37 38def clear_small_scrolls(): 39 nonlocal small_scroll_events 40 small_scroll_events = v'[]' 41 42 43def dispatch_small_scrolls(): 44 if small_scroll_events.length: 45 now = window.performance.now() 46 if now - small_scroll_events[-1].time <= 2000: 47 window.setTimeout(dispatch_small_scrolls, 100) 48 return 49 amt = 0 50 for x in small_scroll_events: 51 amt += x.amt 52 clear_small_scrolls() 53 get_boss().report_human_scroll(amt / scroll_viewport.document_block_size()) 54 55 56def add_small_scroll(amt): 57 small_scroll_events.push({'amt': amt, 'time': window.performance.now()}) 58 window.setTimeout(dispatch_small_scrolls, 100) 59 60 61def report_human_scroll(amt): 62 h = scroll_viewport.height() 63 is_large_scroll = (abs(amt) / h) >= 0.5 64 if amt > 0: 65 if is_large_scroll: 66 clear_small_scrolls() 67 get_boss().report_human_scroll(amt / scroll_viewport.document_block_size()) 68 else: 69 add_small_scroll(amt) 70 elif amt is 0 or is_large_scroll: 71 clear_small_scrolls() 72 73 74last_change_spine_item_request = {} 75 76 77def _check_for_scroll_end(func, obj, args, report): 78 before = scroll_viewport.block_pos() 79 should_flip_progression_direction = func.apply(obj, args) 80 81 now = window.performance.now() 82 scroll_animator.sync(now) 83 84 if scroll_viewport.block_pos() is before: 85 csi = current_spine_item() 86 if last_change_spine_item_request.name is csi.name and now - last_change_spine_item_request.at < 2000: 87 return False 88 last_change_spine_item_request.name = csi.name 89 last_change_spine_item_request.at = now 90 go_to_previous_page = args[0] < 0 91 if (should_flip_progression_direction): 92 go_to_previous_page = not go_to_previous_page 93 get_boss().send_message('next_spine_item', previous=go_to_previous_page) 94 return False 95 if report: 96 report_human_scroll(scroll_viewport.block_pos() - before) 97 return True 98 99 100def check_for_scroll_end(func): 101 return def (): 102 return _check_for_scroll_end(func, this, arguments, False) 103 104 105def check_for_scroll_end_and_report(func): 106 return def (): 107 return _check_for_scroll_end(func, this, arguments, True) 108 109 110@check_for_scroll_end_and_report 111def scroll_by_and_check_next_page(y): 112 scroll_viewport.scroll_by_in_block_direction(y) 113 # This indicates to check_for_scroll_end_and_report that it should not 114 # flip the page progression direction. 115 return False 116 117def flow_onwheel(evt): 118 di = db = 0 119 WheelEvent = window.WheelEvent 120 # Y deltas always scroll in the previous and next page direction, 121 # regardless of writing direction, since doing otherwise would 122 # make mouse wheels mostly useless for scrolling in books written 123 # vertically. 124 if evt.deltaY: 125 if evt.deltaMode is WheelEvent.DOM_DELTA_PIXEL: 126 db = evt.deltaY 127 elif evt.deltaMode is WheelEvent.DOM_DELTA_LINE: 128 db = line_height() * evt.deltaY 129 if evt.deltaMode is WheelEvent.DOM_DELTA_PAGE: 130 db = (scroll_viewport.block_size() - 30) * evt.deltaY 131 # X deltas scroll horizontally in both horizontal and vertical books. 132 # It's more natural in both cases. 133 if evt.deltaX: 134 if evt.deltaMode is WheelEvent.DOM_DELTA_PIXEL: 135 dx = evt.deltaX 136 elif evt.deltaMode is WheelEvent.DOM_DELTA_LINE: 137 dx = line_height() * evt.deltaX 138 else: 139 dx = (scroll_viewport.block_size() - 30) * evt.deltaX 140 141 if scroll_viewport.horizontal_writing_mode: 142 di = dx 143 else: 144 # Left goes forward, so make sure left is positive and right is negative, 145 # which is the opposite of what the wheel sends. 146 if scroll_viewport.rtl: 147 db = -dx 148 # Right goes forward, so the sign is correct. 149 else: 150 db = dx 151 if Math.abs(di) >= 1: 152 scroll_viewport.scroll_by_in_inline_direction(di) 153 if Math.abs(db) >= 1: 154 scroll_by_and_check_next_page(db) 155 156@check_for_scroll_end 157def goto_boundary(dir): 158 position = 0 if dir is DIRECTION.Up else scroll_viewport.document_block_size() 159 scroll_viewport.scroll_to_in_block_direction(position) 160 get_boss().report_human_scroll() 161 return False 162 163 164@check_for_scroll_end_and_report 165def scroll_by_page(direction, flip_if_rtl_page_progression): 166 b = scroll_viewport.block_size() - 10 167 scroll_viewport.scroll_by_in_block_direction(b * direction) 168 169 # Let check_for_scroll_end_and_report know whether or not it should flip 170 # the progression direction. 171 return flip_if_rtl_page_progression and rtl_page_progression() 172 173 174def scroll_to_extend_annotation(backward, horizontal, by_page): 175 direction = -1 if backward else 1 176 h = line_height() 177 if by_page: 178 h = (window.innerWidth if horizontal else window.innerHeight) - h 179 if horizontal: 180 before = window.pageXOffset 181 window.scrollBy(h * direction, 0) 182 return window.pageXOffset is not before 183 before = window.pageYOffset 184 window.scrollBy(0, h * direction) 185 return window.pageYOffset is not before 186 187def is_auto_scroll_active(): 188 return scroll_animator.is_active() 189 190 191def start_autoscroll(): 192 scroll_animator.start(DIRECTION.Down, True) 193 194 195def toggle_autoscroll(): 196 if is_auto_scroll_active(): 197 cancel_scroll() 198 running = False 199 else: 200 start_autoscroll() 201 running = True 202 get_boss().send_message('autoscroll_state_changed', running=running) 203 204 205def handle_shortcut(sc_name, evt): 206 if sc_name is 'down': 207 scroll_animator.start(DIRECTION.Down, False) 208 return True 209 if sc_name is 'up': 210 scroll_animator.start(DIRECTION.Up, False) 211 return True 212 if sc_name is 'start_of_file': 213 goto_boundary(DIRECTION.Up) 214 return True 215 if sc_name is 'end_of_file': 216 goto_boundary(DIRECTION.Down) 217 return True 218 if sc_name is 'left': 219 scroll_by_and_check_next_page(-15 if ltr_page_progression() else 15, 0) 220 return True 221 if sc_name is 'right': 222 scroll_by_and_check_next_page(15 if ltr_page_progression() else -15, 0) 223 return True 224 if sc_name is 'start_of_book': 225 get_boss().send_message('goto_doc_boundary', start=True) 226 return True 227 if sc_name is 'end_of_book': 228 get_boss().send_message('goto_doc_boundary', start=False) 229 return True 230 if sc_name is 'pageup': 231 scroll_by_page(-1, False) 232 return True 233 if sc_name is 'pagedown': 234 scroll_by_page(1, False) 235 return True 236 if sc_name is 'toggle_autoscroll': 237 toggle_autoscroll() 238 return True 239 240 if sc_name.startsWith('scrollspeed_'): 241 scroll_animator.sync() 242 243 return False 244 245 246def layout(is_single_page): 247 add_visibility_listener() 248 line_height(True) 249 rem_size(True) 250 set_css(document.body, margin='0', border_width='0', padding='0') 251 body_style = window.getComputedStyle(document.body) 252 # scroll viewport needs to know if we're in vertical mode, 253 # since that will cause scrolling to happen left and right 254 scroll_viewport.initialize_on_layout(body_style) 255 document.documentElement.style.overflow = 'visible' if scroll_viewport.vertical_writing_mode else 'hidden' 256 257def auto_scroll_resume(): 258 scroll_animator.wait = False 259 scroll_animator.sync() 260 261 262def add_visibility_listener(): 263 if add_visibility_listener.done: 264 return 265 add_visibility_listener.done = True 266 # Pause auto-scroll while minimized 267 document.addEventListener("visibilitychange", def(): 268 if (document.visibilityState is 'visible'): 269 scroll_animator.sync() 270 else: 271 scroll_animator.pause() 272 ) 273 274 275def cancel_scroll(): 276 scroll_animator.stop() 277 278 279def is_scroll_end(pos): 280 return not (0 <= pos <= scroll_viewport.document_block_size() - scroll_viewport.block_size()) 281 282 283DIRECTION = {'Up': -1, 'up': -1, 'Down': 1, 'down': 1, 'UP': -1, 'DOWN': 1} 284 285 286class ScrollAnimator: 287 DURATION = 100 # milliseconds 288 289 def __init__(self): 290 self.animation_id = None 291 self.auto = False 292 self.auto_timer = None 293 self.paused = False 294 295 def is_running(self): 296 return self.animation_id is not None or self.auto_timer is not None 297 298 def is_active(self): 299 return self.is_running() and (self.auto or self.auto_timer is not None) 300 301 def start(self, direction, auto): 302 cancel_drag_scroll() 303 if self.wait: 304 return 305 306 now = window.performance.now() 307 self.end_time = now + self.DURATION 308 self.stop_auto_spine_transition() 309 310 if not self.is_running() or direction is not self.direction or auto is not self.auto: 311 if self.auto and not auto: 312 self.pause() 313 self.stop() 314 self.auto = auto 315 self.direction = direction 316 self.start_time = now 317 self.start_offset = scroll_viewport.block_pos() 318 self.csi_idx = current_spine_item().index 319 self.animation_id = window.requestAnimationFrame(self.auto_scroll if auto else self.smooth_scroll) 320 321 def smooth_scroll(self, ts): 322 duration = self.end_time - self.start_time 323 if duration <= 0: 324 self.animation_id = None 325 return 326 progress = max(0, min(1, (ts - self.start_time) / duration)) # max/min to account for jitter 327 scroll_target = self.start_offset 328 scroll_target += Math.trunc(self.direction * progress * duration * line_height() * opts.lines_per_sec_smooth) / 1000 329 330 scroll_viewport.scroll_to_in_block_direction(scroll_target) 331 amt = scroll_viewport.block_pos() - self.start_offset 332 333 if is_scroll_end(scroll_target) and (not opts.scroll_stop_boundaries or (abs(amt) < 3 and duration is self.DURATION)): 334 # "Turn the page" if stop at boundaries option is false or 335 # this is a new scroll action and we were already at the end 336 self.animation_id = None 337 self.wait = True 338 report_human_scroll(amt) 339 get_boss().send_message('next_spine_item', previous=self.direction is DIRECTION.Up) 340 elif progress < 1: 341 self.animation_id = window.requestAnimationFrame(self.smooth_scroll) 342 elif self.paused: 343 self.resume() 344 else: 345 self.animation_id = None 346 report_human_scroll(amt) 347 348 def auto_scroll(self, ts): 349 elapsed = max(0, ts - self.start_time) # max to account for jitter 350 scroll_target = self.start_offset 351 scroll_target += Math.trunc(self.direction * elapsed * line_height() * opts.lines_per_sec_auto) / 1000 352 353 scroll_viewport.scroll_to_in_block_direction(scroll_target) 354 scroll_finished = is_scroll_end(scroll_target) 355 356 # report every second 357 if elapsed >= 1000: 358 self.sync(ts) 359 360 if scroll_finished: 361 self.pause() 362 if opts.scroll_auto_boundary_delay >= 0: 363 self.auto_timer = setTimeout(self.request_next_spine_item, opts.scroll_auto_boundary_delay * 1000) 364 else: 365 self.animation_id = window.requestAnimationFrame(self.auto_scroll) 366 367 def request_next_spine_item(self): 368 self.auto_timer = None 369 get_boss().send_message('next_spine_item', previous=self.direction is DIRECTION.Up) 370 371 def report(self): 372 amt = scroll_viewport.block_pos() - self.start_offset 373 if abs(amt) > 0 and self.csi_idx is current_spine_item().index: 374 report_human_scroll(amt) 375 376 def sync(self, ts): 377 if self.auto: 378 self.report() 379 self.csi_idx = current_spine_item().index 380 self.start_time = ts or window.performance.now() 381 self.start_offset = scroll_viewport.block_pos() 382 else: 383 self.resume() 384 385 def stop(self): 386 self.auto = False 387 if self.animation_id is not None: 388 window.cancelAnimationFrame(self.animation_id) 389 self.animation_id = None 390 self.report() 391 self.stop_auto_spine_transition() 392 393 def stop_auto_spine_transition(self): 394 if self.auto_timer is not None: 395 clearTimeout(self.auto_timer) 396 self.auto_timer = None 397 self.paused = False 398 399 def pause(self): 400 if self.auto: 401 self.paused = self.direction 402 self.stop() 403 else: 404 self.paused = False 405 406 # Resume auto-scroll 407 def resume(self): 408 if self.paused: 409 self.start(self.paused, True) 410 self.paused = False 411 412scroll_animator = ScrollAnimator() 413 414 415class FlickAnimator: 416 417 SPEED_FACTOR = 0.04 418 DECEL_TIME_CONSTANT = 325 # milliseconds 419 VELOCITY_HISTORY = 300 # milliseconds 420 MIMUMUM_VELOCITY = 100 # pixels/sec 421 422 def __init__(self): 423 self.animation_id = None 424 425 def start(self, gesture): 426 cancel_drag_scroll() 427 self.vertical = gesture.axis is 'vertical' 428 now = window.performance.now() 429 points = times = None 430 for i, t in enumerate(gesture.times): 431 if now - t < self.VELOCITY_HISTORY: 432 points, times = gesture.points[i:], gesture.times[i:] 433 break 434 if times and times.length > 1: 435 elapsed = (times[-1] - times[0]) / 1000 436 if elapsed > 0 and points.length > 1: 437 delta = points[0] - points[-1] 438 velocity = delta / elapsed 439 if abs(velocity) > self.MIMUMUM_VELOCITY: 440 self.amplitude = self.SPEED_FACTOR * velocity 441 self.start_time = now 442 self.animation_id = window.requestAnimationFrame(self.auto_scroll) 443 444 def auto_scroll(self, ts): 445 if self.animation_id is None: 446 return 447 elapsed = window.performance.now() - self.start_time 448 delta = self.amplitude * Math.exp(-elapsed / self.DECEL_TIME_CONSTANT) 449 if abs(delta) >= 1: 450 delta = Math.round(delta) 451 if self.vertical: 452 window.scrollBy(0, delta) 453 else: 454 window.scrollBy(delta, 0) 455 self.animation_id = window.requestAnimationFrame(self.auto_scroll) 456 457 def stop(self): 458 if self.animation_id is not None: 459 window.cancelAnimationFrame(self.animation_id) 460 self.animation_id = None 461 462flick_animator = FlickAnimator() 463 464 465class DragScroller: 466 467 DURATION = 100 # milliseconds 468 469 def __init__(self): 470 self.animation_id = None 471 self.direction = 1 472 self.speed_factor = 1 473 self.start_time = self.end_time = 0 474 self.start_offset = 0 475 476 def is_running(self): 477 return self.animation_id is not None 478 479 def smooth_scroll(self, ts): 480 duration = self.end_time - self.start_time 481 if duration <= 0: 482 self.animation_id = None 483 self.start(self.direction, self.speed_factor) 484 return 485 progress = max(0, min(1, (ts - self.start_time) / duration)) # max/min to account for jitter 486 scroll_target = self.start_offset 487 scroll_target += Math.trunc(self.direction * progress * duration * line_height() * opts.lines_per_sec_smooth * self.speed_factor) / 1000 488 scroll_viewport.scroll_to_in_block_direction(scroll_target) 489 490 if progress < 1: 491 self.animation_id = window.requestAnimationFrame(self.smooth_scroll) 492 else: 493 self.animation_id = None 494 self.start(self.direction, self.speed_factor) 495 496 def start(self, direction, speed_factor): 497 now = window.performance.now() 498 self.end_time = now + self.DURATION 499 if not self.is_running() or direction is not self.direction or speed_factor is not self.speed_factor: 500 self.stop() 501 self.direction = direction 502 self.speed_factor = speed_factor 503 self.start_time = now 504 self.start_offset = scroll_viewport.block_pos() 505 self.animation_id = window.requestAnimationFrame(self.smooth_scroll) 506 507 def stop(self): 508 if self.animation_id is not None: 509 window.cancelAnimationFrame(self.animation_id) 510 self.animation_id = None 511 512 513drag_scroller = DragScroller() 514 515 516def cancel_drag_scroll(): 517 drag_scroller.stop() 518 519 520def start_drag_scroll(delta): 521 limit = opts.margin_top if delta < 0 else opts.margin_bottom 522 direction = 1 if delta >= 0 else -1 523 speed_factor = min(abs(delta), limit) / limit 524 speed_factor *= (2 - speed_factor) # QuadOut Easing curve 525 drag_scroller.start(direction, 2 * speed_factor) 526 527 528def handle_gesture(gesture): 529 flick_animator.stop() 530 if gesture.type is 'swipe': 531 if gesture.points.length > 1 and not gesture.is_held: 532 delta = gesture.points[-2] - gesture.points[-1] 533 if Math.abs(delta) >= 1: 534 if gesture.axis is 'vertical': 535 # Vertical writing scrolls left and right, 536 # so doing a vertical flick shouldn't change pages. 537 if scroll_viewport.vertical_writing_mode: 538 scroll_viewport.scroll_by(delta, 0) 539 # However, it might change pages in horizontal writing 540 else: 541 scroll_by_and_check_next_page(delta) 542 else: 543 # A horizontal flick should check for new pages in 544 # vertical modes, since they flow left and right. 545 if scroll_viewport.vertical_writing_mode: 546 scroll_by_and_check_next_page(delta) 547 # In horizontal modes, just move by the delta. 548 else: 549 scroll_viewport.scroll_by(delta, 0) 550 if not gesture.active and not gesture.is_held: 551 flick_animator.start(gesture) 552 elif gesture.type is 'prev-page': 553 # should flip = False - previous is previous whether RTL or LTR. 554 # flipping of this command is handled higher up 555 scroll_by_page(-1, False) 556 elif gesture.type is 'next-page': 557 # should flip = False - next is next whether RTL or LTR. 558 # flipping of this command is handled higher up 559 scroll_by_page(1, False) 560 561 562anchor_funcs = { 563 'pos_for_elem': def pos_for_elem(elem): 564 if not elem: 565 return {'block': 0, 'inline': 0} 566 br = elem.getBoundingClientRect() 567 568 # Start of object in the scrolling direction 569 return { 570 'block': scroll_viewport.viewport_to_document_block(scroll_viewport.rect_block_start(br), elem.ownerDocument), 571 'inline': scroll_viewport.viewport_to_document_inline(scroll_viewport.rect_inline_start(br), elem.ownerDocument) 572 } 573 , 574 'visibility': def visibility(pos): 575 q = pos.block 576 # Have to negate X if in RTL for the math to be correct, 577 # as the value that the scroll viewport returns is negated 578 if scroll_viewport.vertical_writing_mode and scroll_viewport.rtl: 579 q = -q 580 581 if q + 1 < scroll_viewport.block_pos(): 582 # the + 1 is needed for visibility when top aligned, see: https://bugs.launchpad.net/calibre/+bug/1924890 583 return -1 584 if q <= scroll_viewport.block_pos() + scroll_viewport.block_size(): 585 return 0 586 return 1 587 , 588 'cmp': def cmp(a, b): 589 return (a.block - b.block) or (a.inline - b.inline) 590 , 591} 592 593 594def auto_scroll_action(action): 595 if action is 'toggle': 596 toggle_autoscroll() 597 elif action is 'start': 598 if not is_auto_scroll_active(): 599 toggle_autoscroll() 600 elif action is 'stop': 601 if is_auto_scroll_active(): 602 toggle_autoscroll() 603 elif action is 'resume': 604 auto_scroll_resume() 605 return is_auto_scroll_active() 606 607 608def closest_preceding_element(p): 609 while p and not p.scrollIntoView: 610 p = p.previousSibling or p.parentNode 611 return p 612 613 614def ensure_selection_visible(): 615 s = window.getSelection() 616 p = s.anchorNode 617 if not p: 618 return 619 p = closest_preceding_element(p) 620 if p?.scrollIntoView: 621 p.scrollIntoView() 622 r = s.getRangeAt(0) 623 if not r: 624 return 625 rect = r.getBoundingClientRect() 626 if not rect: 627 return 628 if rect.top < 0 or rect.top >= window.innerHeight or rect.left < 0 or rect.left >= window.innerWidth: 629 wrapper = document.createElement('span') 630 wrap_range(r, wrapper) 631 wrapper.scrollIntoView() 632 unwrap(wrapper) 633 634 635def ensure_selection_boundary_visible(use_end): 636 sel = window.getSelection() 637 try: 638 rr = sel.getRangeAt(0) 639 except: 640 rr = None 641 if rr: 642 r = rr.getBoundingClientRect() 643 if r: 644 node = sel.focusNode if use_end else sel.anchorNode 645 x = scroll_viewport.rect_inline_end(r) if use_end else scroll_viewport.rect_inline_start(r) 646 if x < 0 or x >= window.innerWidth: 647 x = scroll_viewport.viewport_to_document_inline(x, doc=node.ownerDocument) 648 if use_end: 649 x -= line_height() 650 scroll_viewport.scroll_to_in_inline_direction(x, True) 651 y = scroll_viewport.rect_block_end(r) if use_end else scroll_viewport.rect_block_start(r) 652 if y < 0 or y >= window.innerHeight: 653 y = scroll_viewport.viewport_to_document_block(y, doc=node.ownerDocument) 654 if use_end: 655 y -= line_height() 656 scroll_viewport.scroll_to_in_block_direction(y, True) 657 658 659def jump_to_cfi(cfi): 660 # Jump to the position indicated by the specified conformal fragment 661 # indicator. 662 cfi_scroll_to(cfi, def(x, y): 663 # block is vertical if text is horizontal 664 if scroll_viewport.horizontal_writing_mode: 665 scroll_viewport.scroll_to_in_block_direction(y) 666 # block is horizontal if text is vertical 667 else: 668 scroll_viewport.scroll_to_in_block_direction(x) 669 ) 670