Compare commits

..
Author SHA1 Message Date
Twyco 752ba2cd38 Merge pull request 'KAN-71: Use interactive WebKit gestures on iOS and macOS' (#169) from fix/KAN-71-native-navigation-scroll into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m31s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m2s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m24s
CI/CD / test_and_check_app (push) Successful in 6m43s
CI/CD / Publish immutable image (push) Successful in 1m20s
Reviewed-on: #169
2026-09-29 06:04:38 +00:00
Twyco 1cf7524a7b 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
2026-09-29 08:02:10 +02:00
16 changed files with 110 additions and 499 deletions
+10
View File
@@ -1,5 +1,15 @@
# Working Agreements # Working Agreements
## Native app versions and build numbers
For every delivered change under `native/`, increment the app version and bundle/build number for the affected platforms in the same change before committing. Do this for fixes and follow-up changes too; do not wait for a separate release or a user reminder.
- macOS changes require a higher macOS app version and `bundle.macOS.bundleVersion`.
- iOS changes require a higher iOS app version and `bundle.iOS.bundleVersion`, including the matching Xcode `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in both `project.yml` and `project.pbxproj`.
- Shared changes require a higher general native app version and higher bundle/build numbers for every affected platform. Android changes also require a higher `bundle.android.versionCode`; this metadata rule does not imply Android build support.
- The app version is currently shared: keep `native/package.json`, `native/src-tauri/tauri.conf.json`, the native package entries in `Cargo.toml` and `Cargo.lock`, and the Xcode marketing versions consistent. Use a patch increment unless the requested release calls for a minor or major increment. Never decrease or reuse an existing build number.
- Update the native release notes and run the existing version consistency test. One coordinated increment per delivered change is sufficient; intermediate edits and test reruns do not need additional increments.
## Missing programs and SDKs ## Missing programs and SDKs
If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project. If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project.
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "kanbangit-native", "name": "kanbangit-native",
"version": "0.2.4", "version": "0.2.5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -25,5 +25,5 @@
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "2.11.4" "@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."
} }
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]] [[package]]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.2.4" version = "0.2.5"
dependencies = [ dependencies = [
"semver", "semver",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.2.4" version = "0.2.5"
description = "KanbanGit native application" description = "KanbanGit native application"
edition = "2021" edition = "2021"
publish = false 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 defaults: UserDefaults
private let serverKey = "ios.activeServer" private let serverKey = "ios.activeServer"
private(set) var server: WorkspaceServer? private(set) var server: WorkspaceServer?
@@ -40,9 +40,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
private let connectButton = UIButton(type: .system) private let connectButton = UIButton(type: .system)
private let resumeButton = UIButton(type: .system) private let resumeButton = UIButton(type: .system)
private let toolbar = UIToolbar() private let toolbar = UIToolbar()
private var gestureStart = CGPoint.zero
private var gestureAllowed = false
private var gestureRevision = 0
private var appearance: UISegmentedControl! private var appearance: UISegmentedControl!
init(defaults: UserDefaults = .standard) { init(defaults: UserDefaults = .standard) {
@@ -216,17 +213,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
let webview = WKWebView(frame: .zero, configuration: configuration) let webview = WKWebView(frame: .zero, configuration: configuration)
webview.navigationDelegate = self webview.navigationDelegate = self
webview.uiDelegate = self webview.uiDelegate = self
// Native WebKit swipes can skip async pushState entries. One edge recognizer webview.allowsBackForwardNavigationGestures = true
// owns each gesture and traverses the same history that Inertia restores.
webview.allowsBackForwardNavigationGestures = false
webview.scrollView.bounces = false 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.accessibilityIdentifier = "workspace"
webview.translatesAutoresizingMaskIntoConstraints = false webview.translatesAutoresizingMaskIntoConstraints = false
webview.isHidden = true webview.isHidden = true
@@ -285,45 +273,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
workspace?.isHidden = false 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) { func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return } guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
guard server?.allows(url) == true else { guard server?.allows(url) == true else {
@@ -95,7 +95,7 @@ final class WorkspaceTests: XCTestCase {
} }
@MainActor @MainActor
func testGesturesTraverseEverySPAEntryOnceAndPreserveForwardHistory() async throws { func testWebKitOwnsInteractiveGesturesAndKeepsScrollBounds() async throws {
let name = "KAN71-history-\(UUID())" let name = "KAN71-history-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) } defer { defaults.removePersistentDomain(forName: name) }
@@ -113,44 +113,16 @@ final class WorkspaceTests: XCTestCase {
""") """)
try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'") try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'")
XCTAssertFalse(webview.canGoBack) 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'") try await waitFor(webview, "location.pathname === '/dashboard'")
for index in 1...3 { XCTAssertTrue(webview.allowsBackForwardNavigationGestures)
_ = try await webview.evaluateJavaScript("history.pushState({page: {url: '/page\(index)'}}, '', '/page\(index)')") XCTAssertFalse(webview.scrollView.bounces)
} XCTAssertFalse(controller.responds(to: NSSelectorFromString("navigateGesture:")))
_ = try await webview.evaluateJavaScript("window.steps = 0; addEventListener('popstate', () => window.steps++)") let customNavigation = try await webview.evaluateJavaScript("typeof window.__kanbanNavigation")
let edge = TestEdgeGesture() XCTAssertEqual(customNavigation as? String, "undefined")
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")
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value") let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
XCTAssertEqual(draft as? String, "unsaved") 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) let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" }) XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" })
XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" }) XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" })
@@ -158,10 +130,9 @@ final class WorkspaceTests: XCTestCase {
XCTAssertEqual(scroll as? String, "none") XCTAssertEqual(scroll as? String, "none")
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX") let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
XCTAssertEqual(horizontal as? String, "auto") XCTAssertEqual(horizontal as? String, "auto")
_ = try await webview.evaluateJavaScript("document.body.insertAdjacentHTML('beforeend', '<div role=dialog style=\"position:fixed;inset:0\">Dialog</div>')") controller.open(URL(string: "https://other.invalid/")!)
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1)) XCTAssertTrue(try XCTUnwrap(controller.workspace).allowsBackForwardNavigationGestures,
try await Task.sleep(for: .milliseconds(200)) "Changing workspaces must retain native gesture navigation")
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
} }
@MainActor @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_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = ZZX2KZGTHU; DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = 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=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)"; "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_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit; PRODUCT_NAME = KanbanGit;
@@ -618,7 +618,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = ZZX2KZGTHU; DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = 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=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)"; "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_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit; PRODUCT_NAME = KanbanGit;
+2 -2
View File
@@ -18,8 +18,8 @@ settingGroups:
DEVELOPMENT_TEAM: ZZX2KZGTHU DEVELOPMENT_TEAM: ZZX2KZGTHU
ENABLE_USER_SCRIPT_SANDBOXING: false ENABLE_USER_SCRIPT_SANDBOXING: false
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
MARKETING_VERSION: "0.2.4" MARKETING_VERSION: "0.2.5"
CURRENT_PROJECT_VERSION: "4" CURRENT_PROJECT_VERSION: "5"
configs: configs:
debug: debug:
CODE_SIGN_IDENTITY: Apple Development CODE_SIGN_IDENTITY: Apple Development
+1 -97
View File
@@ -1,102 +1,6 @@
#import <AppKit/AppKit.h>
#import <WebKit/WebKit.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) { void install_workspace_navigation(void *pointer) {
WKWebView *webview = (__bridge WKWebView *)pointer; WKWebView *webview = (__bridge WKWebView *)pointer;
webview.allowsBackForwardNavigationGestures = NO; webview.allowsBackForwardNavigationGestures = YES;
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);
} }
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit", "productName": "KanbanGit",
"version": "0.2.4", "version": "0.2.5",
"identifier": "de.kanbangit.desktop", "identifier": "de.kanbangit.desktop",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
@@ -25,7 +25,7 @@
], ],
"macOS": { "macOS": {
"minimumSystemVersion": "14.0", "minimumSystemVersion": "14.0",
"bundleVersion": "9", "bundleVersion": "10",
"dmg": { "dmg": {
"windowSize": { "windowSize": {
"width": 660, "width": 660,
+1 -1
View File
@@ -5,7 +5,7 @@
"iOS": { "iOS": {
"minimumSystemVersion": "17.0", "minimumSystemVersion": "17.0",
"developmentTeam": "ZZX2KZGTHU", "developmentTeam": "ZZX2KZGTHU",
"bundleVersion": "4" "bundleVersion": "5"
} }
} }
} }
+3 -21
View File
@@ -125,27 +125,9 @@ fn main() {
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input); input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax'; document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
localStorage.setItem('kan52_fixture', 'isolated'); localStorage.setItem('kan52_fixture', 'isolated');
const initialHistory = history.length; if (typeof window.__kanbanNavigation !== 'undefined') throw new Error('Custom gesture bridge must be removed');
const initialPath = location.pathname; if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
const navigation = window.__kanbanNavigation; const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
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;
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok; location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
})(); })();
"#).unwrap(); "#).unwrap();
+8 -65
View File
@@ -25,74 +25,17 @@ await page.addInitScript(script);
const root = `http://127.0.0.1:${server.address().port}`; const root = `http://127.0.0.1:${server.address().port}`;
try { try {
await page.goto(root); 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( assert.equal(
await page.evaluate(() => history.state.rememberedState.draft), await page.evaluate(() => typeof window.__kanbanNavigation),
'retained', 'undefined',
); );
await page.goto(`${root}/full-page`); await page.goto(`${root}/full-page`);
await page.evaluate(() => window.__kanbanNavigation.step(-1)); await page.goBack();
await page.waitForURL(`${root}/ticket`); await page.waitForURL(`${root}/`);
await page.evaluate(() => window.__kanbanNavigation.step(1)); await page.goForward();
await page.waitForURL(`${root}/full-page`); await page.waitForURL(`${root}/full-page`);
await page.locator('dialog').evaluate((dialog) => dialog.showModal()); await page.locator('#draft').fill('unsaved draft');
assert.equal( assert.equal(await page.locator('#draft').inputValue(), 'unsaved draft');
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,
);
assert.equal( assert.equal(
await page.evaluate( await page.evaluate(
() => () =>
@@ -143,7 +86,7 @@ try {
() => document.querySelector('dialog').scrollTop > 0, () => document.querySelector('dialog').scrollTop > 0,
); );
console.log( 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 { } finally {
await browser.close(); await browser.close();
+59 -127
View File
@@ -1,142 +1,74 @@
#import <AppKit/AppKit.h>
#import <WebKit/WebKit.h>
#import "../src-tauri/src/navigation.m" #import "../src-tauri/src/navigation.m"
// Exercise the AppKit event adapter deterministically; macos_smoke separately static void check(BOOL condition, NSString *message) {
// runs the shared history script against a real WKWebView. if (!condition) {
@interface TestWindow : NSWindow fprintf(stderr, "FAIL: %s\n", message.UTF8String);
@end exit(1);
@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);
} }
@end
@interface TestEvent : NSEvent static void waitUntil(BOOL (^condition)(void), NSString *message) {
@property(nonatomic, strong) NSWindow *testWindow; NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:10];
@property(nonatomic) NSEventPhase testPhase; while (!condition() && deadline.timeIntervalSinceNow > 0) {
@property(nonatomic) NSEventPhase testMomentum; [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]];
@property(nonatomic) CGFloat dx; }
@property(nonatomic) CGFloat dy; check(condition(), message);
@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;
} }
@interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
@property(nonatomic) NSUInteger finished;
@end @end
@interface TestNavigation : KanbanNavigation @implementation WorkspacePages
@property(nonatomic) BOOL enabled; - (void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)task {
@end NSData *body = [[NSString stringWithFormat:@"<html><body>%@<input value='draft'></body></html>", task.request.URL.path]
@implementation TestNavigation dataUsingEncoding:NSUTF8StringEncoding];
- (BOOL)swipeTrackingEnabled { return self.enabled; } 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 @end
int main(void) { int main(void) {
@autoreleasepool { @autoreleasepool {
[NSApplication sharedApplication]; [NSApplication sharedApplication];
TestWindow *window = [[TestWindow alloc] initWithContentRect:NSMakeRect(0, 0, 600, 400) styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; WorkspacePages *pages = [WorkspacePages new];
TestWebView *webview = [[TestWebView alloc] initWithFrame:NSMakeRect(0, 0, 600, 400)]; WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
webview.testWindow = window; configuration.websiteDataStore = WKWebsiteDataStore.nonPersistentDataStore;
webview.testURL = [NSURL URLWithString:@"https://example.com/board"]; [configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"];
webview.back = YES; WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration];
webview.forward = YES; webview.navigationDelegate = pages;
webview.allowsSwipe = YES; check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
webview.steps = [NSMutableArray new]; install_workspace_navigation((__bridge void *)webview);
TestNavigation *navigation = [TestNavigation new]; install_workspace_navigation((__bridge void *)webview);
navigation.webview = webview; check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures");
navigation.enabled = YES; check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter");
TestEvent *(^event)(void) = ^{ check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate");
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");
for (NSInteger test = 0; test < 7; test++) { [webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/first"]]];
TestEvent *ignored = event(); waitUntil(^BOOL { return pages.finished == 1; }, @"First page must load");
navigation.enabled = test != 0; check(!webview.canGoBack && !webview.canGoForward, @"The first page must have no navigation target");
webview.allowsSwipe = test != 1; [webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/second"]]];
webview.back = webview.forward = test != 2; waitUntil(^BOOL { return pages.finished == 2; }, @"Second page must load");
if (test == 3) ignored.precise = NO; check(webview.canGoBack && !webview.canGoForward, @"Native history must retain the first page");
if (test == 4) ignored.dy = 30; check([webview goBack] != nil, @"Native back navigation must start");
if (test == 5) ignored.testMomentum = NSEventPhaseChanged; waitUntil(^BOOL { return pages.finished == 3; }, @"Native back navigation must complete");
if (test == 6) ignored.testWindow = nil; check([webview.URL.path isEqualToString:@"/first"] && !webview.canGoBack && webview.canGoForward,
NSCAssert([navigation handleEvent:ignored] == ignored && ignored.trackCount == 0, @"Non-navigation events must remain available for scrolling"); @"Back must restore the first page and retain forward history");
} check([webview goBack] == nil, @"Back at the first page must leave it in place");
navigation.enabled = YES; check([webview goForward] != nil, @"Native forward navigation must start");
webview.allowsSwipe = YES; waitUntil(^BOOL { return pages.finished == 4; }, @"Native forward navigation must complete");
webview.back = YES; check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward,
webview.deferPermission = YES; @"Forward must restore the second page");
TestEvent *stale = event(); check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation");
[navigation handleEvent:stale]; puts("PASS: WebKit interactive gesture configuration, full-page back/forward and empty history");
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");
} }
return 0;
} }
+2 -2
View File
@@ -8,7 +8,7 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
test( 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' }, { skip: process.platform !== 'darwin' },
() => { () => {
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-')); const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
@@ -50,7 +50,7 @@ test(
0, 0,
result.stderr || result.error?.message, result.stderr || result.error?.message,
); );
assert.match(result.stdout, /PASS: AppKit/); assert.match(result.stdout, /PASS: WebKit/);
} finally { } finally {
rmSync(directory, { recursive: true, force: true }); rmSync(directory, { recursive: true, force: true });
} }
+1 -65
View File
@@ -1,21 +1,8 @@
(() => { (() => {
if (window.top !== window || window.__kanbanNavigation) { if (window.top !== window) {
return; 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 = () => { const constrainScroll = () => {
if (document.documentElement) { if (document.documentElement) {
document.documentElement.style.overscrollBehaviorY = 'none'; document.documentElement.style.overscrollBehaviorY = 'none';
@@ -28,55 +15,4 @@
document.addEventListener('DOMContentLoaded', constrainScroll, { document.addEventListener('DOMContentLoaded', constrainScroll, {
once: true, 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);
}
},
});
})(); })();