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:
@@ -1,5 +1,15 @@
|
||||
# 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
|
||||
|
||||
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
@@ -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");
|
||||
[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");
|
||||
}
|
||||
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");
|
||||
}
|
||||
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