fix(KAN-71): use interactive WebKit navigation gestures
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m13s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m44s
CI/CD / test_and_check_app (pull_request) Successful in 7m4s
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m13s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m44s
CI/CD / test_and_check_app (pull_request) Successful in 7m4s
CI/CD / Publish immutable image (pull_request) Skipped
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kanbangit-native",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -25,5 +25,5 @@
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.11.4"
|
||||
},
|
||||
"releaseNotes": "Correct iOS and macOS navigation gestures, preserve single-step page history, and constrain vertical overscroll."
|
||||
"releaseNotes": "Use WebKit's interactive back and forward gestures on iOS and macOS while retaining vertical scroll bounds."
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -1770,7 +1770,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kanbangit-native"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kanbangit-native"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
description = "KanbanGit native application"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -24,7 +24,7 @@ func installIOSWorkspace() {
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate, UIGestureRecognizerDelegate {
|
||||
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate {
|
||||
private let defaults: UserDefaults
|
||||
private let serverKey = "ios.activeServer"
|
||||
private(set) var server: WorkspaceServer?
|
||||
@@ -40,9 +40,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
private let connectButton = UIButton(type: .system)
|
||||
private let resumeButton = UIButton(type: .system)
|
||||
private let toolbar = UIToolbar()
|
||||
private var gestureStart = CGPoint.zero
|
||||
private var gestureAllowed = false
|
||||
private var gestureRevision = 0
|
||||
private var appearance: UISegmentedControl!
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
@@ -216,17 +213,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
let webview = WKWebView(frame: .zero, configuration: configuration)
|
||||
webview.navigationDelegate = self
|
||||
webview.uiDelegate = self
|
||||
// Native WebKit swipes can skip async pushState entries. One edge recognizer
|
||||
// owns each gesture and traverses the same history that Inertia restores.
|
||||
webview.allowsBackForwardNavigationGestures = false
|
||||
webview.allowsBackForwardNavigationGestures = true
|
||||
webview.scrollView.bounces = false
|
||||
for edge: UIRectEdge in [.left, .right] {
|
||||
let gesture = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(navigateGesture(_:)))
|
||||
gesture.edges = edge
|
||||
gesture.delegate = self
|
||||
webview.addGestureRecognizer(gesture)
|
||||
webview.scrollView.panGestureRecognizer.require(toFail: gesture)
|
||||
}
|
||||
webview.accessibilityIdentifier = "workspace"
|
||||
webview.translatesAutoresizingMaskIntoConstraints = false
|
||||
webview.isHidden = true
|
||||
@@ -285,45 +273,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
workspace?.isHidden = false
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
guard gestureRecognizer is UIScreenEdgePanGestureRecognizer, let webview = workspace else { return false }
|
||||
gestureStart = touch.location(in: webview)
|
||||
gestureAllowed = false
|
||||
gestureRevision += 1
|
||||
let revision = gestureRevision
|
||||
webview.evaluateJavaScript("window.__kanbanNavigation?.canSwipe(\(gestureStart.x), \(gestureStart.y)) === true") { [weak self, weak gestureRecognizer] result, _ in
|
||||
guard let self, self.gestureRevision == revision, self.workspace === webview else { return }
|
||||
self.gestureAllowed = result as? Bool == true
|
||||
// Release the scroll view immediately when a board, field or overlay owns the touch.
|
||||
if !self.gestureAllowed, gestureRecognizer?.state == .possible { gestureRecognizer?.state = .failed }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let edge = gestureRecognizer as? UIScreenEdgePanGestureRecognizer,
|
||||
let webview = workspace, !webview.isHidden, gestureAllowed else { return false }
|
||||
let velocity = edge.velocity(in: webview)
|
||||
return abs(velocity.x) > abs(velocity.y)
|
||||
&& (edge.edges == .left ? webview.canGoBack && velocity.x > 0 : webview.canGoForward && velocity.x < 0)
|
||||
}
|
||||
|
||||
@objc private func navigateGesture(_ gesture: UIScreenEdgePanGestureRecognizer) {
|
||||
guard gesture.state == .ended, let webview = workspace else { return }
|
||||
let direction = gesture.edges == .left ? -1 : 1
|
||||
let distance = gesture.translation(in: webview).x * CGFloat(-direction)
|
||||
let velocity = gesture.velocity(in: webview).x * CGFloat(-direction)
|
||||
guard distance > webview.bounds.width * 0.25 || (distance > 30 && velocity > 500) else { return }
|
||||
navigateHistory(direction, from: gestureStart)
|
||||
}
|
||||
|
||||
func navigateHistory(_ direction: Int, from point: CGPoint) {
|
||||
guard let webview = workspace,
|
||||
direction == -1 ? webview.canGoBack : webview.canGoForward else { return }
|
||||
let script = "if (window.__kanbanNavigation?.canSwipe(\(point.x), \(point.y))) window.__kanbanNavigation.step(\(direction))"
|
||||
webview.evaluateJavaScript(script, completionHandler: nil)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
||||
guard server?.allows(url) == true else {
|
||||
|
||||
@@ -95,7 +95,7 @@ final class WorkspaceTests: XCTestCase {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testGesturesTraverseEverySPAEntryOnceAndPreserveForwardHistory() async throws {
|
||||
func testWebKitOwnsInteractiveGesturesAndKeepsScrollBounds() async throws {
|
||||
let name = "KAN71-history-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
@@ -113,44 +113,16 @@ final class WorkspaceTests: XCTestCase {
|
||||
""")
|
||||
try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'")
|
||||
XCTAssertFalse(webview.canGoBack)
|
||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||
XCTAssertNil(webview.goBack())
|
||||
XCTAssertNil(webview.goForward())
|
||||
try await waitFor(webview, "location.pathname === '/dashboard'")
|
||||
for index in 1...3 {
|
||||
_ = try await webview.evaluateJavaScript("history.pushState({page: {url: '/page\(index)'}}, '', '/page\(index)')")
|
||||
}
|
||||
_ = try await webview.evaluateJavaScript("window.steps = 0; addEventListener('popstate', () => window.steps++)")
|
||||
let edge = TestEdgeGesture()
|
||||
edge.edges = .left
|
||||
edge.testVelocity = CGPoint(x: 800, y: 0)
|
||||
XCTAssertTrue(controller.gestureRecognizer(edge, shouldReceive: TestTouch()))
|
||||
var allowed = false
|
||||
for _ in 0..<50 {
|
||||
if controller.gestureRecognizerShouldBegin(edge) { allowed = true; break }
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
XCTAssertTrue(allowed)
|
||||
edge.testState = .cancelled
|
||||
edge.testTranslation = CGPoint(x: 200, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
||||
edge.testState = .ended
|
||||
edge.testTranslation = CGPoint(x: 10, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
||||
edge.testTranslation = CGPoint(x: 200, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 1")
|
||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||
try await waitFor(webview, "location.pathname === '/page1' && window.steps === 2")
|
||||
controller.navigateHistory(1, from: CGPoint(x: 1, y: 1))
|
||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
|
||||
XCTAssertTrue(webview.allowsBackForwardNavigationGestures)
|
||||
XCTAssertFalse(webview.scrollView.bounces)
|
||||
XCTAssertFalse(controller.responds(to: NSSelectorFromString("navigateGesture:")))
|
||||
let customNavigation = try await webview.evaluateJavaScript("typeof window.__kanbanNavigation")
|
||||
XCTAssertEqual(customNavigation as? String, "undefined")
|
||||
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
||||
XCTAssertEqual(draft as? String, "unsaved")
|
||||
XCTAssertFalse(webview.scrollView.bounces)
|
||||
XCTAssertFalse(webview.allowsBackForwardNavigationGestures)
|
||||
XCTAssertEqual(webview.gestureRecognizers?.compactMap { $0 as? UIScreenEdgePanGestureRecognizer }.count, 2)
|
||||
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||
XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" })
|
||||
XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" })
|
||||
@@ -158,10 +130,9 @@ final class WorkspaceTests: XCTestCase {
|
||||
XCTAssertEqual(scroll as? String, "none")
|
||||
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
|
||||
XCTAssertEqual(horizontal as? String, "auto")
|
||||
_ = try await webview.evaluateJavaScript("document.body.insertAdjacentHTML('beforeend', '<div role=dialog style=\"position:fixed;inset:0\">Dialog</div>')")
|
||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||
try await Task.sleep(for: .milliseconds(200))
|
||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
|
||||
controller.open(URL(string: "https://other.invalid/")!)
|
||||
XCTAssertTrue(try XCTUnwrap(controller.workspace).allowsBackForwardNavigationGestures,
|
||||
"Changing workspaces must retain native gesture navigation")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -272,19 +243,3 @@ final class WorkspaceTests: XCTestCase {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private final class TestTouch: UITouch {
|
||||
override func location(in view: UIView?) -> CGPoint { CGPoint(x: 1, y: 1) }
|
||||
}
|
||||
|
||||
private final class TestEdgeGesture: UIScreenEdgePanGestureRecognizer {
|
||||
var testState: UIGestureRecognizer.State = .possible
|
||||
var testVelocity = CGPoint.zero
|
||||
var testTranslation = CGPoint.zero
|
||||
override var state: UIGestureRecognizer.State {
|
||||
get { testState }
|
||||
set { testState = newValue }
|
||||
}
|
||||
override func velocity(in view: UIView?) -> CGPoint { testVelocity }
|
||||
override func translation(in view: UIView?) -> CGPoint { testTranslation }
|
||||
}
|
||||
|
||||
@@ -441,7 +441,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -457,7 +457,7 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
MARKETING_VERSION = 0.2.4;
|
||||
MARKETING_VERSION = 0.2.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
@@ -618,7 +618,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -634,7 +634,7 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
MARKETING_VERSION = 0.2.4;
|
||||
MARKETING_VERSION = 0.2.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
|
||||
@@ -18,8 +18,8 @@ settingGroups:
|
||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
|
||||
MARKETING_VERSION: "0.2.4"
|
||||
CURRENT_PROJECT_VERSION: "4"
|
||||
MARKETING_VERSION: "0.2.5"
|
||||
CURRENT_PROJECT_VERSION: "5"
|
||||
configs:
|
||||
debug:
|
||||
CODE_SIGN_IDENTITY: Apple Development
|
||||
|
||||
@@ -1,102 +1,6 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <WebKit/WebKit.h>
|
||||
#import <objc/runtime.h>
|
||||
|
||||
// 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);
|
||||
webview.allowsBackForwardNavigationGestures = YES;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "KanbanGit",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"identifier": "de.kanbangit.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
@@ -25,7 +25,7 @@
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "14.0",
|
||||
"bundleVersion": "9",
|
||||
"bundleVersion": "10",
|
||||
"dmg": {
|
||||
"windowSize": {
|
||||
"width": 660,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"iOS": {
|
||||
"minimumSystemVersion": "17.0",
|
||||
"developmentTeam": "ZZX2KZGTHU",
|
||||
"bundleVersion": "4"
|
||||
"bundleVersion": "5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,27 +125,9 @@ fn main() {
|
||||
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
|
||||
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
|
||||
localStorage.setItem('kan52_fixture', 'isolated');
|
||||
const initialHistory = history.length;
|
||||
const initialPath = location.pathname;
|
||||
const navigation = window.__kanbanNavigation;
|
||||
if (!navigation || getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
|
||||
const traverse = direction => new Promise(resolve => {
|
||||
addEventListener('popstate', resolve, { once: true });
|
||||
navigation.step(direction);
|
||||
});
|
||||
for (const hash of ['#board', '#ticket', '#settings']) history.pushState({ page: { url: hash } }, '', hash);
|
||||
await traverse(-1);
|
||||
if (location.hash !== '#ticket') throw new Error('Back skipped an SPA entry');
|
||||
await traverse(-1);
|
||||
if (location.hash !== '#board') throw new Error('Back skipped a second SPA entry');
|
||||
await traverse(1);
|
||||
if (location.hash !== '#ticket' || history.length !== initialHistory + 3) throw new Error('Forward history was lost');
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.textContent = 'Navigation overlay'; document.body.append(dialog); dialog.showModal();
|
||||
if (navigation.canSwipe(1, 1)) throw new Error('Overlay must own navigation');
|
||||
dialog.close(); dialog.remove();
|
||||
if (input.value !== 'unsaved input') throw new Error('History discarded form state');
|
||||
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && initialPath === '/login' && initialHistory === 1;
|
||||
if (typeof window.__kanbanNavigation !== 'undefined') throw new Error('Custom gesture bridge must be removed');
|
||||
if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
|
||||
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
|
||||
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
||||
})();
|
||||
"#).unwrap();
|
||||
|
||||
@@ -25,74 +25,17 @@ await page.addInitScript(script);
|
||||
const root = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
await page.goto(root);
|
||||
const length = await page.evaluate(() => history.length);
|
||||
for (const path of ['/board', '/ticket', '/settings']) {
|
||||
await page.evaluate(
|
||||
(path) =>
|
||||
history.pushState(
|
||||
{
|
||||
page: { url: path },
|
||||
rememberedState: { draft: 'retained' },
|
||||
},
|
||||
'',
|
||||
path,
|
||||
),
|
||||
path,
|
||||
);
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
window.steps = 0;
|
||||
addEventListener('popstate', () => window.steps++);
|
||||
});
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(-1));
|
||||
await page.waitForFunction(() => location.pathname === '/ticket');
|
||||
assert.equal(await page.evaluate(() => window.steps), 1);
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(-1));
|
||||
await page.waitForFunction(() => location.pathname === '/board');
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(1));
|
||||
await page.waitForFunction(() => location.pathname === '/ticket');
|
||||
assert.equal(await page.evaluate(() => window.steps), 3);
|
||||
assert.equal(await page.evaluate(() => history.length), length + 3);
|
||||
assert.equal(await page.locator('#draft').inputValue(), 'retained');
|
||||
assert.equal(
|
||||
await page.evaluate(() => history.state.rememberedState.draft),
|
||||
'retained',
|
||||
await page.evaluate(() => typeof window.__kanbanNavigation),
|
||||
'undefined',
|
||||
);
|
||||
await page.goto(`${root}/full-page`);
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(-1));
|
||||
await page.waitForURL(`${root}/ticket`);
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(1));
|
||||
await page.goBack();
|
||||
await page.waitForURL(`${root}/`);
|
||||
await page.goForward();
|
||||
await page.waitForURL(`${root}/full-page`);
|
||||
await page.locator('dialog').evaluate((dialog) => dialog.showModal());
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||
false,
|
||||
);
|
||||
await page.locator('dialog').evaluate((dialog) => dialog.close());
|
||||
for (const selector of ['#board', '#draft']) {
|
||||
const point = await page.locator(selector).boundingBox();
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
({ x, y }) => window.__kanbanNavigation.canSwipe(x + 5, y + 5),
|
||||
point,
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||
true,
|
||||
);
|
||||
await page.dispatchEvent('#board', 'dragstart');
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||
false,
|
||||
);
|
||||
await page.dispatchEvent('#board', 'dragend');
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||
true,
|
||||
);
|
||||
await page.locator('#draft').fill('unsaved draft');
|
||||
assert.equal(await page.locator('#draft').inputValue(), 'unsaved draft');
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
@@ -143,7 +86,7 @@ try {
|
||||
() => document.querySelector('dialog').scrollTop > 0,
|
||||
);
|
||||
console.log(
|
||||
'PASS: SPA/full-page history, forward history, overlays, short/long scroll bounds, horizontal board, drag and input guards',
|
||||
'PASS: browser-owned history, editable input, short/long scroll bounds, horizontal board and dialog scrolling',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
+59
-127
@@ -1,142 +1,74 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <WebKit/WebKit.h>
|
||||
#import "../src-tauri/src/navigation.m"
|
||||
|
||||
// Exercise the AppKit event adapter deterministically; macos_smoke separately
|
||||
// runs the shared history script against a real WKWebView.
|
||||
@interface TestWindow : NSWindow
|
||||
@end
|
||||
@implementation TestWindow
|
||||
- (BOOL)isKeyWindow { return YES; }
|
||||
@end
|
||||
|
||||
@interface TestWebView : WKWebView
|
||||
@property(nonatomic, strong) NSWindow *testWindow;
|
||||
@property(nonatomic, strong) NSURL *testURL;
|
||||
@property(nonatomic) BOOL back;
|
||||
@property(nonatomic) BOOL forward;
|
||||
@property(nonatomic) BOOL allowsSwipe;
|
||||
@property(nonatomic) BOOL deferPermission;
|
||||
@property(nonatomic, copy) void (^permission)(id, NSError *);
|
||||
@property(nonatomic, strong) NSMutableArray<NSString *> *steps;
|
||||
@end
|
||||
@implementation TestWebView
|
||||
- (NSWindow *)window { return self.testWindow; }
|
||||
- (NSURL *)URL { return self.testURL; }
|
||||
- (BOOL)canGoBack { return self.back; }
|
||||
- (BOOL)canGoForward { return self.forward; }
|
||||
- (NSPoint)convertPoint:(NSPoint)point fromView:(NSView *)view { return point; }
|
||||
- (void)evaluateJavaScript:(NSString *)script completionHandler:(void (^)(id, NSError *))handler {
|
||||
if ([script containsString:@".step("]) [self.steps addObject:script];
|
||||
else if (self.deferPermission) self.permission = handler;
|
||||
else handler(@(self.allowsSwipe), nil);
|
||||
static void check(BOOL condition, NSString *message) {
|
||||
if (!condition) {
|
||||
fprintf(stderr, "FAIL: %s\n", message.UTF8String);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
@end
|
||||
|
||||
@interface TestEvent : NSEvent
|
||||
@property(nonatomic, strong) NSWindow *testWindow;
|
||||
@property(nonatomic) NSEventPhase testPhase;
|
||||
@property(nonatomic) NSEventPhase testMomentum;
|
||||
@property(nonatomic) CGFloat dx;
|
||||
@property(nonatomic) CGFloat dy;
|
||||
@property(nonatomic) BOOL precise;
|
||||
@property(nonatomic) NSInteger trackCount;
|
||||
@property(nonatomic, copy) void (^tracking)(CGFloat, NSEventPhase, BOOL, BOOL *);
|
||||
@end
|
||||
@implementation TestEvent
|
||||
- (NSWindow *)window { return self.testWindow; }
|
||||
- (NSEventPhase)phase { return self.testPhase; }
|
||||
- (NSEventPhase)momentumPhase { return self.testMomentum; }
|
||||
- (CGFloat)scrollingDeltaX { return self.dx; }
|
||||
- (CGFloat)scrollingDeltaY { return self.dy; }
|
||||
- (BOOL)hasPreciseScrollingDeltas { return self.precise; }
|
||||
- (NSPoint)locationInWindow { return NSMakePoint(20, 20); }
|
||||
- (void)trackSwipeEventWithOptions:(NSEventSwipeTrackingOptions)options dampenAmountThresholdMin:(CGFloat)minimum max:(CGFloat)maximum usingHandler:(void (^)(CGFloat, NSEventPhase, BOOL, BOOL *))handler {
|
||||
NSCAssert(options & NSEventSwipeTrackingLockDirection, @"Direction must be locked");
|
||||
self.trackCount++;
|
||||
self.tracking = handler;
|
||||
static void waitUntil(BOOL (^condition)(void), NSString *message) {
|
||||
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:10];
|
||||
while (!condition() && deadline.timeIntervalSinceNow > 0) {
|
||||
[NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]];
|
||||
}
|
||||
check(condition(), message);
|
||||
}
|
||||
|
||||
@interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
|
||||
@property(nonatomic) NSUInteger finished;
|
||||
@end
|
||||
|
||||
@interface TestNavigation : KanbanNavigation
|
||||
@property(nonatomic) BOOL enabled;
|
||||
@end
|
||||
@implementation TestNavigation
|
||||
- (BOOL)swipeTrackingEnabled { return self.enabled; }
|
||||
@implementation WorkspacePages
|
||||
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)task {
|
||||
NSData *body = [[NSString stringWithFormat:@"<html><body>%@<input value='draft'></body></html>", task.request.URL.path]
|
||||
dataUsingEncoding:NSUTF8StringEncoding];
|
||||
NSURLResponse *response = [[NSURLResponse alloc] initWithURL:task.request.URL MIMEType:@"text/html"
|
||||
expectedContentLength:body.length textEncodingName:@"utf-8"];
|
||||
[task didReceiveResponse:response];
|
||||
[task didReceiveData:body];
|
||||
[task didFinish];
|
||||
}
|
||||
- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id<WKURLSchemeTask>)task {}
|
||||
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
|
||||
self.finished++;
|
||||
}
|
||||
@end
|
||||
|
||||
int main(void) {
|
||||
@autoreleasepool {
|
||||
[NSApplication sharedApplication];
|
||||
TestWindow *window = [[TestWindow alloc] initWithContentRect:NSMakeRect(0, 0, 600, 400) styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
||||
TestWebView *webview = [[TestWebView alloc] initWithFrame:NSMakeRect(0, 0, 600, 400)];
|
||||
webview.testWindow = window;
|
||||
webview.testURL = [NSURL URLWithString:@"https://example.com/board"];
|
||||
webview.back = YES;
|
||||
webview.forward = YES;
|
||||
webview.allowsSwipe = YES;
|
||||
webview.steps = [NSMutableArray new];
|
||||
TestNavigation *navigation = [TestNavigation new];
|
||||
navigation.webview = webview;
|
||||
navigation.enabled = YES;
|
||||
TestEvent *(^event)(void) = ^{
|
||||
TestEvent *value = [TestEvent new];
|
||||
value.testWindow = window;
|
||||
value.testPhase = NSEventPhaseBegan;
|
||||
value.precise = YES;
|
||||
value.dx = 15;
|
||||
return value;
|
||||
};
|
||||
BOOL stop = NO;
|
||||
TestEvent *back = event();
|
||||
[navigation handleEvent:back];
|
||||
NSCAssert(back.trackCount == 1, @"A horizontal gesture must use AppKit tracking");
|
||||
back.tracking(0.5, NSEventPhaseChanged, NO, &stop);
|
||||
NSCAssert(webview.steps.count == 0, @"A partial swipe must not navigate");
|
||||
back.tracking(1, NSEventPhaseNone, YES, &stop);
|
||||
NSCAssert(webview.steps.count == 1 && [webview.steps.lastObject containsString:@"step(-1)"], @"Back must request exactly one step");
|
||||
TestEvent *forward = event();
|
||||
forward.dx = -15;
|
||||
[navigation handleEvent:forward];
|
||||
forward.tracking(-1, NSEventPhaseNone, YES, &stop);
|
||||
NSCAssert(webview.steps.count == 2 && [webview.steps.lastObject containsString:@"step(1)"], @"Forward must request exactly one step");
|
||||
TestEvent *cancelled = event();
|
||||
[navigation handleEvent:cancelled];
|
||||
cancelled.tracking(0, NSEventPhaseCancelled, YES, &stop);
|
||||
NSCAssert(webview.steps.count == 2, @"Cancelled swipes must not navigate");
|
||||
TestEvent *interrupted = event();
|
||||
[navigation handleEvent:interrupted];
|
||||
webview.allowsSwipe = NO;
|
||||
[navigation handleEvent:event()];
|
||||
interrupted.tracking(1, NSEventPhaseNone, YES, &stop);
|
||||
NSCAssert(webview.steps.count == 2, @"A new gesture must cancel the previous gesture's animation callback");
|
||||
WorkspacePages *pages = [WorkspacePages new];
|
||||
WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
|
||||
configuration.websiteDataStore = WKWebsiteDataStore.nonPersistentDataStore;
|
||||
[configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"];
|
||||
WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration];
|
||||
webview.navigationDelegate = pages;
|
||||
check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
|
||||
install_workspace_navigation((__bridge void *)webview);
|
||||
install_workspace_navigation((__bridge void *)webview);
|
||||
check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures");
|
||||
check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter");
|
||||
check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate");
|
||||
|
||||
for (NSInteger test = 0; test < 7; test++) {
|
||||
TestEvent *ignored = event();
|
||||
navigation.enabled = test != 0;
|
||||
webview.allowsSwipe = test != 1;
|
||||
webview.back = webview.forward = test != 2;
|
||||
if (test == 3) ignored.precise = NO;
|
||||
if (test == 4) ignored.dy = 30;
|
||||
if (test == 5) ignored.testMomentum = NSEventPhaseChanged;
|
||||
if (test == 6) ignored.testWindow = nil;
|
||||
NSCAssert([navigation handleEvent:ignored] == ignored && ignored.trackCount == 0, @"Non-navigation events must remain available for scrolling");
|
||||
}
|
||||
navigation.enabled = YES;
|
||||
webview.allowsSwipe = YES;
|
||||
webview.back = YES;
|
||||
webview.deferPermission = YES;
|
||||
TestEvent *stale = event();
|
||||
[navigation handleEvent:stale];
|
||||
TestEvent *ended = event();
|
||||
ended.testPhase = NSEventPhaseEnded;
|
||||
[navigation handleEvent:ended];
|
||||
webview.permission(@YES, nil);
|
||||
NSCAssert(stale.trackCount == 0, @"Late hit tests must not resurrect ended gestures");
|
||||
TestEvent *changedPage = event();
|
||||
[navigation handleEvent:changedPage];
|
||||
webview.testURL = [NSURL URLWithString:@"https://example.com/settings"];
|
||||
webview.permission(@YES, nil);
|
||||
NSCAssert(changedPage.trackCount == 0, @"Page changes must invalidate a pending gesture");
|
||||
puts("PASS: AppKit back/forward, cancelled/partial swipes, system preference, nested scroll ownership, empty history, mouse/vertical/momentum events, stale callbacks");
|
||||
[webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/first"]]];
|
||||
waitUntil(^BOOL { return pages.finished == 1; }, @"First page must load");
|
||||
check(!webview.canGoBack && !webview.canGoForward, @"The first page must have no navigation target");
|
||||
[webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/second"]]];
|
||||
waitUntil(^BOOL { return pages.finished == 2; }, @"Second page must load");
|
||||
check(webview.canGoBack && !webview.canGoForward, @"Native history must retain the first page");
|
||||
check([webview goBack] != nil, @"Native back navigation must start");
|
||||
waitUntil(^BOOL { return pages.finished == 3; }, @"Native back navigation must complete");
|
||||
check([webview.URL.path isEqualToString:@"/first"] && !webview.canGoBack && webview.canGoForward,
|
||||
@"Back must restore the first page and retain forward history");
|
||||
check([webview goBack] == nil, @"Back at the first page must leave it in place");
|
||||
check([webview goForward] != nil, @"Native forward navigation must start");
|
||||
waitUntil(^BOOL { return pages.finished == 4; }, @"Native forward navigation must complete");
|
||||
check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward,
|
||||
@"Forward must restore the second page");
|
||||
check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation");
|
||||
puts("PASS: WebKit interactive gesture configuration, full-page back/forward and empty history");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
test(
|
||||
'AppKit gestures preserve single steps, cancellation, system preferences and scroll ownership',
|
||||
'WebKit owns interactive navigation and preserves native back/forward history',
|
||||
{ skip: process.platform !== 'darwin' },
|
||||
() => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
|
||||
@@ -50,7 +50,7 @@ test(
|
||||
0,
|
||||
result.stderr || result.error?.message,
|
||||
);
|
||||
assert.match(result.stdout, /PASS: AppKit/);
|
||||
assert.match(result.stdout, /PASS: WebKit/);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
(() => {
|
||||
if (window.top !== window || window.__kanbanNavigation) {
|
||||
if (window.top !== window) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dragging = false;
|
||||
const overlay = () =>
|
||||
[
|
||||
...document.querySelectorAll(
|
||||
'dialog[open], [role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [data-reka-popper-content-wrapper]',
|
||||
),
|
||||
].some(
|
||||
(element) =>
|
||||
element.getAttribute('data-state') !== 'closed' &&
|
||||
element.getClientRects().length > 0 &&
|
||||
getComputedStyle(element).visibility !== 'hidden',
|
||||
);
|
||||
|
||||
const constrainScroll = () => {
|
||||
if (document.documentElement) {
|
||||
document.documentElement.style.overscrollBehaviorY = 'none';
|
||||
@@ -28,55 +15,4 @@
|
||||
document.addEventListener('DOMContentLoaded', constrainScroll, {
|
||||
once: true,
|
||||
});
|
||||
document.addEventListener('dragstart', () => {
|
||||
dragging = true;
|
||||
});
|
||||
document.addEventListener('dragend', () => {
|
||||
dragging = false;
|
||||
});
|
||||
document.addEventListener('drop', () => {
|
||||
dragging = false;
|
||||
});
|
||||
|
||||
window.__kanbanNavigation = Object.freeze({
|
||||
canSwipe(x, y) {
|
||||
if (dragging || overlay()) {
|
||||
return false;
|
||||
}
|
||||
for (
|
||||
let element = document.elementFromPoint(x, y);
|
||||
element;
|
||||
element = element.parentElement
|
||||
) {
|
||||
if (
|
||||
element.matches(
|
||||
'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [draggable="true"]',
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const style = getComputedStyle(element);
|
||||
if (
|
||||
/(auto|scroll)/.test(style.overflowX) &&
|
||||
element.scrollWidth > element.clientWidth
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
style.overscrollBehaviorX === 'contain' ||
|
||||
style.overscrollBehaviorX === 'none'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
step(direction) {
|
||||
// The host checks canGoBack/canGoForward. JS traversal preserves SPA entries
|
||||
// that WebKit's user-initiated history traversal is allowed to skip.
|
||||
if (direction === -1 || direction === 1) {
|
||||
window.history.go(direction);
|
||||
}
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user