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 5from read_book.globals import get_boss, ui_operations, ltr_page_progression 6from read_book.viewport import scroll_viewport 7from read_book.settings import opts 8 9HOLD_THRESHOLD = 750 # milliseconds 10TAP_THRESHOLD = 8 # pixels 11SWIPE_THRESHOLD = 64 # pixels 12TAP_LINK_THRESHOLD = 5 # pixels 13PINCH_THRESHOLD = 20 # pixels 14 15gesture_id = 0 16 17def touch_id(touch): 18 # On Safari using touch.identifier as dict key yields a key of "NaN" for some reason 19 return touch.identifier + '' 20 21def copy_touch(t): 22 now = window.performance.now() 23 return { 24 'identifier':touch_id(t), 25 'page_x':v'[t.pageX]', 'page_y':v'[t.pageY]', 'viewport_x':v'[t.clientX]', 'viewport_y':v'[t.clientY]', 26 'active':True, 'mtimes':v'[now]', 'ctime':now, 'is_held':False, 'x_velocity': 0, 'y_velocity': 0 27 } 28 29 30def update_touch(t, touch): 31 now = window.performance.now() 32 t.mtimes.push(now) 33 t.page_x.push(touch.pageX), t.page_y.push(touch.pageY) 34 t.viewport_x.push(touch.clientX), t.viewport_y.push(touch.clientY) 35 36 37def max_displacement(points): 38 ans = 0 39 first = points[0] 40 if not first?: 41 return ans 42 for p in points: 43 delta = abs(p - first) 44 if delta > ans: 45 ans = delta 46 return ans 47 48def interpret_single_gesture(touch, gesture_id): 49 max_x_displacement = max_displacement(touch.viewport_x) 50 max_y_displacement = max_displacement(touch.viewport_y) 51 ans = {'active':touch.active, 'is_held':touch.is_held, 'id':gesture_id, 'start_time': touch.ctime} 52 if max(max_x_displacement, max_y_displacement) < TAP_THRESHOLD: 53 ans.type = 'tap' 54 ans.viewport_x = touch.viewport_x[0] 55 ans.viewport_y = touch.viewport_y[0] 56 return ans 57 if touch.viewport_y.length < 2: 58 return ans 59 delta_x = abs(touch.viewport_x[-1] - touch.viewport_x[0]) 60 delta_y = abs(touch.viewport_y[-1] - touch.viewport_y[0]) 61 max_disp = max(delta_y, delta_x) 62 if max_disp > SWIPE_THRESHOLD and min(delta_x, delta_y)/max_disp < 0.35: 63 ans.type = 'swipe' 64 ans.axis = 'vertical' if delta_y > delta_x else 'horizontal' 65 ans.points = pts = touch.viewport_y if ans.axis is 'vertical' else touch.viewport_x 66 ans.times = touch.mtimes 67 positive = pts[-1] > pts[0] 68 if ans.axis is 'vertical': 69 ans.direction = 'down' if positive else 'up' 70 ans.velocity = touch.y_velocity 71 else: 72 ans.direction = 'right' if positive else 'left' 73 ans.velocity = touch.x_velocity 74 return ans 75 return ans 76 77 78def interpret_double_gesture(touch1, touch2, gesture_id): 79 ans = {'active':touch1.active or touch2.active, 'is_held':touch1.is_held or touch2.is_held, 'id':gesture_id} 80 max_x_displacement1 = max_displacement(touch1.viewport_x) 81 max_x_displacement2 = max_displacement(touch2.viewport_x) 82 max_y_displacement1 = max_displacement(touch1.viewport_y) 83 max_y_displacement2 = max_displacement(touch2.viewport_y) 84 if max(max_x_displacement1, max_y_displacement1) < TAP_THRESHOLD and max(max_x_displacement2, max_y_displacement2) < TAP_THRESHOLD: 85 ans.type = 'double-tap' 86 ans.viewport_x1 = touch1.viewport_x[0] 87 ans.viewport_y1 = touch1.viewport_y[0] 88 ans.viewport_x2 = touch2.viewport_x[0] 89 ans.viewport_y2 = touch2.viewport_y[0] 90 return ans 91 initial_distance = Math.sqrt((touch1.viewport_x[0] - touch2.viewport_x[0])**2 + (touch1.viewport_y[0] - touch2.viewport_y[0])**2) 92 final_distance = Math.sqrt((touch1.viewport_x[-1] - touch2.viewport_x[-1])**2 + (touch1.viewport_y[-1] - touch2.viewport_y[-1])**2) 93 distance = abs(final_distance - initial_distance) 94 if distance > PINCH_THRESHOLD: 95 ans.type = 'pinch' 96 ans.direction = 'in' if final_distance < initial_distance else 'out' 97 ans.distance = distance 98 return ans 99 return ans 100 101def element_from_point(x, y): 102 # This does not currently support detecting links inside iframes, but since 103 # iframes are not common in books, I am going to ignore that for now 104 return document.elementFromPoint(x, y) 105 106 107def find_link(x, y): 108 p = element_from_point(x, y) 109 while p: 110 if p.tagName and p.tagName.toLowerCase() is 'a' and p.hasAttribute('href'): 111 return p 112 p = p.parentNode 113 114 115def tap_on_link(gesture): 116 for delta_x in [0, TAP_LINK_THRESHOLD, -TAP_LINK_THRESHOLD]: 117 for delta_y in [0, TAP_LINK_THRESHOLD, -TAP_LINK_THRESHOLD]: 118 x = gesture.viewport_x + delta_x 119 y = gesture.viewport_y + delta_y 120 link = find_link(x, y) 121 if link: 122 link.click() 123 return True 124 return False 125 126 127class TouchHandler: 128 129 def __init__(self): 130 self.ongoing_touches = {} 131 self.gesture_id = None 132 self.hold_timer = None 133 self.handled_tap_hold = False 134 135 def prune_expired_touches(self): 136 now = window.performance.now() 137 expired = v'[]' 138 for tid in self.ongoing_touches: 139 t = self.ongoing_touches[tid] 140 if t.active: 141 if now - t.mtimes[-1] > 3000: 142 expired.push(touch_id(t)) 143 for tid in expired: 144 v'delete self.ongoing_touches[tid]' 145 146 @property 147 def has_active_touches(self): 148 for tid in self.ongoing_touches: 149 t = self.ongoing_touches[tid] 150 if t.active: 151 return True 152 return False 153 154 def reset_handlers(self): 155 self.stop_hold_timer() 156 self.ongoing_touches = {} 157 self.gesture_id = None 158 self.handled_tap_hold = False 159 160 def start_hold_timer(self): 161 self.stop_hold_timer() 162 self.hold_timer = window.setTimeout(self.check_for_hold, 50) 163 164 def stop_hold_timer(self): 165 if self.hold_timer is not None: 166 window.clearTimeout(self.hold_timer) 167 self.hold_timer = None 168 169 def check_for_hold(self): 170 if len(self.ongoing_touches) > 0: 171 now = window.performance.now() 172 found_hold = False 173 for touchid in self.ongoing_touches: 174 touch = self.ongoing_touches[touchid] 175 if touch.active and now - touch.mtimes[-1] > HOLD_THRESHOLD: 176 touch.is_held = True 177 found_hold = True 178 if found_hold: 179 self.dispatch_gesture() 180 self.start_hold_timer() 181 182 def handle_touchstart(self, ev): 183 ev.preventDefault(), ev.stopPropagation() 184 self.prune_expired_touches() 185 for touch in ev.changedTouches: 186 self.ongoing_touches[touch_id(touch)] = copy_touch(touch) 187 if self.gesture_id is None: 188 nonlocal gesture_id 189 gesture_id += 1 190 self.gesture_id = gesture_id 191 self.handled_tap_hold = False 192 if len(self.ongoing_touches) > 0: 193 self.start_hold_timer() 194 195 def handle_touchmove(self, ev): 196 ev.preventDefault(), ev.stopPropagation() 197 for touch in ev.changedTouches: 198 t = self.ongoing_touches[touch_id(touch)] 199 if t: 200 update_touch(t, touch) 201 self.dispatch_gesture() 202 203 def handle_touchend(self, ev): 204 ev.preventDefault(), ev.stopPropagation() 205 for touch in ev.changedTouches: 206 t = self.ongoing_touches[touch_id(touch)] 207 if t: 208 t.active = False 209 update_touch(t, touch) 210 self.prune_expired_touches() 211 if not self.has_active_touches: 212 self.dispatch_gesture() 213 self.reset_handlers() 214 215 def handle_touchcancel(self, ev): 216 ev.preventDefault(), ev.stopPropagation() 217 for touch in ev.changedTouches: 218 tid = touch_id(touch) # noqa: unused-local 219 v'delete self.ongoing_touches[tid]' 220 self.gesture_id = None 221 self.handled_tap_hold = False 222 223 def dispatch_gesture(self): 224 touches = self.ongoing_touches 225 num = len(touches) 226 gesture = {} 227 if num is 1: 228 gesture = interpret_single_gesture(touches[Object.keys(touches)[0]], self.gesture_id) 229 elif num is 2: 230 t = Object.keys(touches) 231 gesture = interpret_double_gesture(touches[t[0]], touches[t[1]], self.gesture_id) 232 if not gesture?.type: 233 return 234 self.handle_gesture(gesture) 235 236class BookTouchHandler(TouchHandler): 237 238 def __init__(self, for_side_margin=None): 239 self.for_side_margin = for_side_margin 240 TouchHandler.__init__(self) 241 242 def handle_gesture(self, gesture): 243 gesture.from_side_margin = self.for_side_margin 244 if gesture.type is 'tap': 245 if gesture.is_held: 246 if not self.for_side_margin and not self.handled_tap_hold and window.performance.now() - gesture.start_time >= HOLD_THRESHOLD: 247 self.handled_tap_hold = True 248 gesture.type = 'long-tap' 249 get_boss().handle_gesture(gesture) 250 return 251 if not gesture.active: 252 if self.for_side_margin or not tap_on_link(gesture): 253 if gesture.viewport_y < min(100, scroll_viewport.height() / 4): 254 gesture.type = 'show-chrome' 255 else: 256 # default, books that go left to right. 257 if ltr_page_progression() and not opts.reverse_page_turn_zones: 258 if gesture.viewport_x < min(100, scroll_viewport.width() / 4): 259 gesture.type = 'prev-page' 260 else: 261 gesture.type = 'next-page' 262 # We swap the sizes in RTL mode, so that going to the next page is always the bigger touch region. 263 else: 264 # The "going back" area should not be more than 100 units big, 265 # even if 1/4 of the scroll viewport is more than 100 units. 266 # Checking against the larger of the width minus the 100 units and 3/4 of the width will accomplish that. 267 if gesture.viewport_x > max(scroll_viewport.width() - 100, scroll_viewport.width() * (3/4)): 268 gesture.type = 'prev-page' 269 else: 270 gesture.type = 'next-page' 271 if gesture.type is 'pinch': 272 if gesture.active: 273 return 274 if gesture.type is 'double-tap': 275 if gesture.active: 276 return 277 gesture.type = 'show-chrome' 278 if self.for_side_margin: 279 ui_operations.forward_gesture(gesture) 280 else: 281 get_boss().handle_gesture(gesture) 282 283 def __repr__(self): 284 return 'BookTouchHandler:for_side_margin:' + self.for_side_margin 285 286main_touch_handler = BookTouchHandler() 287left_margin_handler = BookTouchHandler('left') 288right_margin_handler = BookTouchHandler('right') 289 290def install_handlers(elem, handler, passive): 291 options = {'capture': True, 'passive': v'!!passive'} 292 elem.addEventListener('touchstart', handler.handle_touchstart, options) 293 elem.addEventListener('touchmove', handler.handle_touchmove, options) 294 elem.addEventListener('touchend', handler.handle_touchend, options) 295 elem.addEventListener('touchcancel', handler.handle_touchcancel, options) 296 297def create_handlers(): 298 # Safari does not work if we register the handler 299 # on window instead of document 300 install_handlers(document, main_touch_handler) 301 # See https://github.com/kovidgoyal/calibre/pull/1101 302 # for why we need touchAction none 303 document.body.style.touchAction = 'none' 304 305def reset_handlers(): 306 main_touch_handler.reset_handlers() 307 308def set_left_margin_handler(elem): 309 install_handlers(elem, left_margin_handler) 310 311def set_right_margin_handler(elem): 312 install_handlers(elem, right_margin_handler) 313