#import #import #import // Keep AppKit's system swipe preferences and cancellation thresholds, but use // script history traversal so asynchronously created Inertia entries aren't skipped. @interface KanbanNavigation : NSObject @property(nonatomic, weak) WKWebView *webview; @property(nonatomic, strong) id monitor; @property(nonatomic) BOOL pending; @property(nonatomic) BOOL tracking; @property(nonatomic) BOOL blocked; @property(nonatomic) NSUInteger sequence; @end @implementation KanbanNavigation - (BOOL)swipeTrackingEnabled { return NSEvent.isSwipeTrackingFromScrollEventsEnabled; } - (void)dealloc { if (_monitor) [NSEvent removeMonitor:_monitor]; } - (NSEvent *)handleEvent:(NSEvent *)event { WKWebView *webview = self.webview; if (!webview || event.window != webview.window || !webview.window.isKeyWindow || webview.hidden || ![self swipeTrackingEnabled]) return event; if (event.phase == NSEventPhaseBegan) { self.sequence++; self.blocked = NO; self.pending = NO; self.tracking = NO; } if (self.tracking) return nil; if (event.phase == NSEventPhaseEnded || event.phase == NSEventPhaseCancelled) { self.sequence++; self.pending = NO; return event; } if (self.blocked || self.pending || event.momentumPhase != NSEventPhaseNone || !(event.phase & (NSEventPhaseBegan | NSEventPhaseChanged)) || !event.hasPreciseScrollingDeltas) return event; if (fabs(event.scrollingDeltaX) <= fabs(event.scrollingDeltaY)) { if (fabs(event.scrollingDeltaY) > 0) self.blocked = YES; return event; } NSPoint point = [webview convertPoint:event.locationInWindow fromView:nil]; if (!NSPointInRect(point, webview.bounds)) return event; if (!webview.isFlipped) point.y = webview.bounds.size.height - point.y; NSString *script = [NSString stringWithFormat:@"window.__kanbanNavigation?.canSwipe(%f, %f) === true", point.x, point.y]; self.pending = YES; NSUInteger sequence = self.sequence; NSURL *page = webview.URL; __weak KanbanNavigation *weakSelf = self; [webview evaluateJavaScript:script completionHandler:^(id allowed, NSError *error) { KanbanNavigation *owner = weakSelf; if (!owner || owner.sequence != sequence) return; owner.pending = NO; owner.blocked = YES; if (error || ![allowed isEqual:@YES] || ![webview.URL isEqual:page] || (!webview.canGoBack && !webview.canGoForward)) return; owner.tracking = YES; [event trackSwipeEventWithOptions:NSEventSwipeTrackingLockDirection | NSEventSwipeTrackingClampGestureAmount dampenAmountThresholdMin:webview.canGoForward ? -1 : 0 max:webview.canGoBack ? 1 : 0 usingHandler:^(CGFloat amount, NSEventPhase phase, BOOL complete, BOOL *stop) { KanbanNavigation *current = weakSelf; if (current.sequence != sequence) { *stop = YES; return; } if (!current || !webview.window.isKeyWindow || ![webview.URL isEqual:page]) { current.tracking = NO; *stop = YES; return; } if (!complete) return; current.tracking = NO; if (phase == NSEventPhaseCancelled) return; NSInteger direction = amount >= 1 ? -1 : amount <= -1 ? 1 : 0; if ((direction == -1 && webview.canGoBack) || (direction == 1 && webview.canGoForward)) { NSString *navigate = [NSString stringWithFormat:@"if (%@) window.__kanbanNavigation.step(%ld)", script, (long)direction]; [webview evaluateJavaScript:navigate completionHandler:nil]; } }]; }]; return event; } @end void install_workspace_navigation(void *pointer) { WKWebView *webview = (__bridge WKWebView *)pointer; webview.allowsBackForwardNavigationGestures = NO; KanbanNavigation *navigation = [KanbanNavigation new]; navigation.webview = webview; __weak KanbanNavigation *weakNavigation = navigation; navigation.monitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskScrollWheel handler:^NSEvent *(NSEvent *event) { KanbanNavigation *owner = weakNavigation; return owner ? [owner handleEvent:event] : event; }]; objc_setAssociatedObject(webview, @selector(handleEvent:), navigation, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }