diff --git a/CHANGELOG.md b/CHANGELOG.md index 841ed7bfd..149b2bf0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Enum and set value pickers now populate when the driver reports the values only inside the column type instead of as a separate list. - The plugin list no longer goes stale. The app now checks the plugin registry for changes at launch, when the plugin browser opens, and before reporting a plugin as missing, so newly published plugins show up and install right away. A registry that cannot be reached now reports a connection problem instead of "Plugin not found". (#1799) - Dropping a materialized view or foreign table from the sidebar now generates the correct DROP statement instead of DROP TABLE, and drop and truncate statements are schema-qualified. ClickHouse now lists materialized views as their own sidebar section and drops them with the DROP VIEW syntax it requires. (#1800) +- Scrolling no longer goes dead across the whole app after opening an ER diagram and switching tabs while the pointer rests over the canvas. Diagram pan and zoom now handle scroll events on the canvas itself instead of intercepting them app-wide. (#1805) ## [0.54.0] - 2026-06-30 diff --git a/TablePro/Models/ERDiagram/ERDiagramScrollTranslator.swift b/TablePro/Models/ERDiagram/ERDiagramScrollTranslator.swift new file mode 100644 index 000000000..ce02113f1 --- /dev/null +++ b/TablePro/Models/ERDiagram/ERDiagramScrollTranslator.swift @@ -0,0 +1,26 @@ +import CoreGraphics + +enum ERDiagramScrollAction: Equatable { + case pan(CGPoint) + case zoom(CGFloat) +} + +enum ERDiagramScrollTranslator { + static func action( + scrollingDeltaX: CGFloat, + scrollingDeltaY: CGFloat, + hasPreciseScrollingDeltas: Bool, + isZoomModifierActive: Bool, + currentOffset: CGPoint, + currentMagnification: CGFloat + ) -> ERDiagramScrollAction { + if isZoomModifierActive { + return .zoom(currentMagnification + scrollingDeltaY * 0.01) + } + let multiplier: CGFloat = hasPreciseScrollingDeltas ? 1.0 : 10.0 + return .pan(CGPoint( + x: currentOffset.x + scrollingDeltaX * multiplier, + y: currentOffset.y + scrollingDeltaY * multiplier + )) + } +} diff --git a/TablePro/ViewModels/ERDiagramViewModel.swift b/TablePro/ViewModels/ERDiagramViewModel.swift index 4b82c7311..464fad38c 100644 --- a/TablePro/ViewModels/ERDiagramViewModel.swift +++ b/TablePro/ViewModels/ERDiagramViewModel.swift @@ -53,7 +53,6 @@ final class ERDiagramViewModel { var canvasOffset: CGPoint = .zero var viewportSize: CGSize = .zero - var isMouseOverCanvas = false // MARK: - Drag State diff --git a/TablePro/Views/ERDiagram/ERDiagramCanvasContainer.swift b/TablePro/Views/ERDiagram/ERDiagramCanvasContainer.swift new file mode 100644 index 000000000..bd47c6cdb --- /dev/null +++ b/TablePro/Views/ERDiagram/ERDiagramCanvasContainer.swift @@ -0,0 +1,57 @@ +import AppKit +import SwiftUI + +struct ERDiagramCanvasContainer: NSViewRepresentable { + let viewModel: ERDiagramViewModel + @ViewBuilder let content: () -> Content + + func makeNSView(context: Context) -> ERDiagramCanvasContainerView { + ERDiagramCanvasContainerView(rootView: content(), viewModel: viewModel) + } + + func updateNSView(_ nsView: ERDiagramCanvasContainerView, context: Context) { + nsView.hostingView.rootView = content() + } +} + +@MainActor +final class ERDiagramCanvasContainerView: NSView { + let hostingView: NSHostingView + private let viewModel: ERDiagramViewModel + + init(rootView: Content, viewModel: ERDiagramViewModel) { + self.viewModel = viewModel + hostingView = NSHostingView(rootView: rootView) + super.init(frame: .zero) + hostingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.leadingAnchor.constraint(equalTo: leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: trailingAnchor), + hostingView.topAnchor.constraint(equalTo: topAnchor), + hostingView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) not supported") + } + + override func scrollWheel(with event: NSEvent) { + let action = ERDiagramScrollTranslator.action( + scrollingDeltaX: event.scrollingDeltaX, + scrollingDeltaY: event.scrollingDeltaY, + hasPreciseScrollingDeltas: event.hasPreciseScrollingDeltas, + isZoomModifierActive: event.modifierFlags.contains(.command), + currentOffset: viewModel.canvasOffset, + currentMagnification: viewModel.magnification + ) + switch action { + case .pan(let offset): + viewModel.canvasOffset = offset + case .zoom(let magnification): + viewModel.zoom(to: magnification) + } + } +} diff --git a/TablePro/Views/ERDiagram/ERDiagramView.swift b/TablePro/Views/ERDiagram/ERDiagramView.swift index 5893c176c..8aaf3ebe2 100644 --- a/TablePro/Views/ERDiagram/ERDiagramView.swift +++ b/TablePro/Views/ERDiagram/ERDiagramView.swift @@ -7,7 +7,6 @@ struct ERDiagramView: View { @Bindable var viewModel: ERDiagramViewModel @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor @State private var selectedNodeId: UUID? - @State private var scrollMonitor: Any? @State private var currentCursor: NSCursor? @State private var magnifyStartMag: CGFloat? @@ -48,7 +47,7 @@ struct ERDiagramView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - diagramContent + ERDiagramCanvasContainer(viewModel: viewModel) { diagramContent } } ERDiagramToolbar(viewModel: viewModel, onExport: exportDiagram) .onKeyPress(characters: .init(charactersIn: "c"), phases: .down) { keyPress in @@ -123,7 +122,6 @@ struct ERDiagramView: View { .onContinuousHover { phase in switch phase { case .active(let location): - viewModel.isMouseOverCanvas = true guard !viewModel.isDragging else { return } let desired: NSCursor? = nodeAt(point: location) != nil ? .openHand : nil if desired !== currentCursor { @@ -132,7 +130,6 @@ struct ERDiagramView: View { currentCursor = desired } case .ended: - viewModel.isMouseOverCanvas = false if currentCursor != nil { NSCursor.pop() currentCursor = nil @@ -141,29 +138,6 @@ struct ERDiagramView: View { break } } - .onAppear { - guard scrollMonitor == nil else { return } - scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in - guard viewModel.isMouseOverCanvas else { return event } - if event.modifierFlags.contains(.command) { - let zoomDelta = event.scrollingDeltaY * 0.01 - viewModel.zoom(to: viewModel.magnification + zoomDelta) - return nil - } - let multiplier: CGFloat = event.hasPreciseScrollingDeltas ? 1.0 : 10.0 - viewModel.canvasOffset = CGPoint( - x: viewModel.canvasOffset.x + event.scrollingDeltaX * multiplier, - y: viewModel.canvasOffset.y + event.scrollingDeltaY * multiplier - ) - return nil - } - } - .onDisappear { - if let monitor = scrollMonitor { - NSEvent.removeMonitor(monitor) - scrollMonitor = nil - } - } } // MARK: - Cluster Colors diff --git a/TableProTests/Models/ERDiagram/ERDiagramScrollTranslatorTests.swift b/TableProTests/Models/ERDiagram/ERDiagramScrollTranslatorTests.swift new file mode 100644 index 000000000..adf140438 --- /dev/null +++ b/TableProTests/Models/ERDiagram/ERDiagramScrollTranslatorTests.swift @@ -0,0 +1,77 @@ +import CoreGraphics +import Testing + +@testable import TablePro + +@Suite("ERDiagramScrollTranslator") +struct ERDiagramScrollTranslatorTests { + @Test("precise deltas pan point for point") + func preciseDeltasPanUnscaled() { + let action = ERDiagramScrollTranslator.action( + scrollingDeltaX: 12, + scrollingDeltaY: -8, + hasPreciseScrollingDeltas: true, + isZoomModifierActive: false, + currentOffset: CGPoint(x: 100, y: 50), + currentMagnification: 1.0 + ) + #expect(action == .pan(CGPoint(x: 112, y: 42))) + } + + @Test("line deltas pan with the wheel multiplier") + func lineDeltasPanScaled() { + let action = ERDiagramScrollTranslator.action( + scrollingDeltaX: 2, + scrollingDeltaY: 3, + hasPreciseScrollingDeltas: false, + isZoomModifierActive: false, + currentOffset: .zero, + currentMagnification: 1.0 + ) + #expect(action == .pan(CGPoint(x: 20, y: 30))) + } + + @Test("zoom modifier zooms from the vertical delta") + func zoomModifierZooms() { + let action = ERDiagramScrollTranslator.action( + scrollingDeltaX: 5, + scrollingDeltaY: 50, + hasPreciseScrollingDeltas: true, + isZoomModifierActive: true, + currentOffset: .zero, + currentMagnification: 1.0 + ) + guard case .zoom(let magnification) = action else { + Issue.record("expected zoom, got \(action)") + return + } + #expect(abs(magnification - 1.5) < 0.0001) + } + + @Test("zero deltas keep the offset") + func zeroDeltasKeepOffset() { + let offset = CGPoint(x: 33, y: -7) + let action = ERDiagramScrollTranslator.action( + scrollingDeltaX: 0, + scrollingDeltaY: 0, + hasPreciseScrollingDeltas: true, + isZoomModifierActive: false, + currentOffset: offset, + currentMagnification: 1.0 + ) + #expect(action == .pan(offset)) + } + + @Test("negative deltas invert both axes") + func negativeDeltasInvert() { + let action = ERDiagramScrollTranslator.action( + scrollingDeltaX: -4, + scrollingDeltaY: -6, + hasPreciseScrollingDeltas: false, + isZoomModifierActive: false, + currentOffset: CGPoint(x: 100, y: 100), + currentMagnification: 1.0 + ) + #expect(action == .pan(CGPoint(x: 60, y: 40))) + } +} diff --git a/TableProTests/Views/ERDiagram/ERDiagramCanvasContainerViewTests.swift b/TableProTests/Views/ERDiagram/ERDiagramCanvasContainerViewTests.swift new file mode 100644 index 000000000..c5bc76a94 --- /dev/null +++ b/TableProTests/Views/ERDiagram/ERDiagramCanvasContainerViewTests.swift @@ -0,0 +1,81 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TablePro + +@Suite("ERDiagramCanvasContainerView scroll routing") +@MainActor +struct ERDiagramCanvasContainerViewTests { + private func makeContainer() -> (ERDiagramCanvasContainerView, ERDiagramViewModel) { + let viewModel = ERDiagramViewModel(connectionId: UUID(), schemaKey: "test") + let view = ERDiagramCanvasContainerView(rootView: Color.clear, viewModel: viewModel) + return (view, viewModel) + } + + private func makeScrollEvent( + deltaX: Int32, + deltaY: Int32, + units: CGScrollEventUnit, + flags: CGEventFlags = [] + ) -> NSEvent? { + guard let cgEvent = CGEvent( + scrollWheelEvent2Source: nil, + units: units, + wheelCount: 2, + wheel1: deltaY, + wheel2: deltaX, + wheel3: 0 + ) else { return nil } + cgEvent.flags = flags + return NSEvent(cgEvent: cgEvent) + } + + @Test("trackpad scroll pans the canvas by the event deltas") + func trackpadScrollPans() throws { + let (view, viewModel) = makeContainer() + let event = try #require(makeScrollEvent(deltaX: 12, deltaY: -8, units: .pixel)) + try #require(event.hasPreciseScrollingDeltas) + try #require(event.scrollingDeltaY != 0) + + view.scrollWheel(with: event) + + #expect(viewModel.canvasOffset == CGPoint(x: event.scrollingDeltaX, y: event.scrollingDeltaY)) + } + + @Test("mouse wheel scroll pans with the line multiplier") + func mouseWheelScrollPans() throws { + let (view, viewModel) = makeContainer() + let event = try #require(makeScrollEvent(deltaX: 0, deltaY: 3, units: .line)) + try #require(!event.hasPreciseScrollingDeltas) + try #require(event.scrollingDeltaY != 0) + + view.scrollWheel(with: event) + + #expect(viewModel.canvasOffset.y == event.scrollingDeltaY * 10) + } + + @Test("command scroll zooms through the view model") + func commandScrollZooms() throws { + let (view, viewModel) = makeContainer() + let event = try #require(makeScrollEvent(deltaX: 0, deltaY: 40, units: .pixel, flags: .maskCommand)) + try #require(event.scrollingDeltaY != 0) + + view.scrollWheel(with: event) + + let expected = 1.0 + event.scrollingDeltaY * 0.01 + #expect(abs(viewModel.magnification - expected) < 0.0001) + #expect(viewModel.canvasOffset == .zero) + } + + @Test("command scroll zoom clamps to the maximum magnification") + func commandScrollZoomClamps() throws { + let (view, viewModel) = makeContainer() + let event = try #require(makeScrollEvent(deltaX: 0, deltaY: 400, units: .pixel, flags: .maskCommand)) + try #require(event.scrollingDeltaY >= 200) + + view.scrollWheel(with: event) + + #expect(viewModel.magnification == 3.0) + } +}