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