Update BrushCanvasView and CanvasView, add screenshots

This commit is contained in:
2026-01-25 10:46:17 -05:00
parent 9f35ea751e
commit c3f4126296
12 changed files with 390 additions and 91 deletions

View File

@@ -252,7 +252,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = 7X85543FQQ; DEVELOPMENT_TEAM = 7X85543FQQ;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -292,7 +292,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = 7X85543FQQ; DEVELOPMENT_TEAM = 7X85543FQQ;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;

View File

@@ -2,7 +2,8 @@
// BrushCanvasView.swift // BrushCanvasView.swift
// CheapRetouch // CheapRetouch
// //
// Canvas overlay for brush-based manual selection. // Canvas overlay for brush-based manual selection with support for
// single-finger drawing and two-finger pan/zoom.
// //
import SwiftUI import SwiftUI
@@ -12,13 +13,45 @@ struct BrushCanvasView: View {
@Bindable var viewModel: EditorViewModel @Bindable var viewModel: EditorViewModel
let imageSize: CGSize let imageSize: CGSize
let displayedImageFrame: CGRect let displayedImageFrame: CGRect
@Binding var scale: CGFloat
@Binding var lastScale: CGFloat
@Binding var offset: CGSize
@Binding var lastOffset: CGSize
let minScale: CGFloat
let maxScale: CGFloat
let clampOffset: () -> Void
@State private var currentStroke: [CGPoint] = []
@State private var allStrokes: [[CGPoint]] = [] @State private var allStrokes: [[CGPoint]] = []
@State private var currentStroke: [CGPoint] = []
@State private var currentTouchLocation: CGPoint? @State private var currentTouchLocation: CGPoint?
@State private var gradientImage: EdgeRefinement.GradientImage? @State private var gradientImage: EdgeRefinement.GradientImage?
/// The effective brush size on screen, scaled by zoom level
private var scaledBrushSize: CGFloat {
viewModel.brushSize * scale
}
var body: some View { var body: some View {
GeometryReader { geometry in
ZStack {
// UIKit gesture handler for proper single/two-finger differentiation
// This is at the bottom of the ZStack but receives all touches
BrushGestureView(
displayedImageFrame: displayedImageFrame,
scale: $scale,
lastScale: $lastScale,
offset: $offset,
lastOffset: $lastOffset,
minScale: minScale,
maxScale: maxScale,
currentStroke: $currentStroke,
allStrokes: $allStrokes,
currentTouchLocation: $currentTouchLocation,
clampOffset: clampOffset
)
.frame(width: geometry.size.width, height: geometry.size.height)
// SwiftUI Canvas for rendering strokes (on top for visibility)
Canvas { context, size in Canvas { context, size in
// Draw all completed strokes // Draw all completed strokes
for stroke in allStrokes { for stroke in allStrokes {
@@ -33,10 +66,10 @@ struct BrushCanvasView: View {
// Draw brush preview circle at current touch location // Draw brush preview circle at current touch location
if let location = currentTouchLocation { if let location = currentTouchLocation {
let previewRect = CGRect( let previewRect = CGRect(
x: location.x - viewModel.brushSize / 2, x: location.x - scaledBrushSize / 2,
y: location.y - viewModel.brushSize / 2, y: location.y - scaledBrushSize / 2,
width: viewModel.brushSize, width: scaledBrushSize,
height: viewModel.brushSize height: scaledBrushSize
) )
context.stroke( context.stroke(
Path(ellipseIn: previewRect), Path(ellipseIn: previewRect),
@@ -45,7 +78,9 @@ struct BrushCanvasView: View {
) )
} }
} }
.gesture(drawingGesture) .allowsHitTesting(false) // Let the gesture view handle all touches
}
}
.onAppear { .onAppear {
// Precompute gradient for edge refinement if enabled // Precompute gradient for edge refinement if enabled
if viewModel.useEdgeRefinement, let image = viewModel.displayImage { if viewModel.useEdgeRefinement, let image = viewModel.displayImage {
@@ -59,11 +94,9 @@ struct BrushCanvasView: View {
} }
.onChange(of: viewModel.editedImage) { _, _ in .onChange(of: viewModel.editedImage) { _, _ in
// Recompute gradient when image changes (e.g., after undo/redo) // Recompute gradient when image changes (e.g., after undo/redo)
// to ensure edge refinement works correctly with the current image
if viewModel.useEdgeRefinement, let image = viewModel.displayImage { if viewModel.useEdgeRefinement, let image = viewModel.displayImage {
computeGradientAsync(from: image) computeGradientAsync(from: image)
} else { } else {
// Invalidate cached gradient so it will be recomputed when edge refinement is enabled
gradientImage = nil gradientImage = nil
} }
} }
@@ -119,37 +152,16 @@ struct BrushCanvasView: View {
path, path,
with: .color(color.opacity(0.7)), with: .color(color.opacity(0.7)),
style: StrokeStyle( style: StrokeStyle(
lineWidth: viewModel.brushSize, lineWidth: scaledBrushSize,
lineCap: .round, lineCap: .round,
lineJoin: .round lineJoin: .round
) )
) )
} }
private var drawingGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
let point = value.location
currentTouchLocation = point
// Only add points within the image bounds
if displayedImageFrame.contains(point) {
currentStroke.append(point)
}
}
.onEnded { _ in
currentTouchLocation = nil
if !currentStroke.isEmpty {
allStrokes.append(currentStroke)
currentStroke = []
}
}
}
private func applyBrushMask() async { private func applyBrushMask() async {
DebugLogger.action("BrushCanvasView.applyBrushMask called") DebugLogger.action("BrushCanvasView.applyBrushMask called")
// Check if we have strokes to apply or a pending mask to refine
let hasStrokes = !allStrokes.isEmpty let hasStrokes = !allStrokes.isEmpty
let hasPendingMask = viewModel.pendingRefineMask != nil let hasPendingMask = viewModel.pendingRefineMask != nil
@@ -158,8 +170,6 @@ struct BrushCanvasView: View {
return return
} }
// Get the actual display image dimensions - this may differ from originalImage
// after undo/redo due to preview scaling in renderPreview
guard let displayImage = viewModel.displayImage else { guard let displayImage = viewModel.displayImage else {
DebugLogger.error("No display image available") DebugLogger.error("No display image available")
return return
@@ -172,12 +182,10 @@ struct BrushCanvasView: View {
DebugLogger.log("Displayed frame: \(displayedImageFrame)") DebugLogger.log("Displayed frame: \(displayedImageFrame)")
DebugLogger.log("Has pending mask: \(hasPendingMask)") DebugLogger.log("Has pending mask: \(hasPendingMask)")
// Use actual display image size for scaling, not the original image size
let scaleX = actualImageSize.width / displayedImageFrame.width let scaleX = actualImageSize.width / displayedImageFrame.width
let scaleY = actualImageSize.height / displayedImageFrame.height let scaleY = actualImageSize.height / displayedImageFrame.height
DebugLogger.log("Scale factors: X=\(scaleX), Y=\(scaleY)") DebugLogger.log("Scale factors: X=\(scaleX), Y=\(scaleY)")
// Convert all strokes to image coordinates (using actual display image dimensions)
var imageCoordStrokes: [[CGPoint]] = [] var imageCoordStrokes: [[CGPoint]] = []
for stroke in allStrokes { for stroke in allStrokes {
let imageStroke = stroke.map { point in let imageStroke = stroke.map { point in
@@ -189,7 +197,6 @@ struct BrushCanvasView: View {
imageCoordStrokes.append(imageStroke) imageCoordStrokes.append(imageStroke)
} }
// Apply edge refinement if enabled and gradient is available
if viewModel.useEdgeRefinement, let gradient = gradientImage { if viewModel.useEdgeRefinement, let gradient = gradientImage {
imageCoordStrokes = imageCoordStrokes.map { stroke in imageCoordStrokes = imageCoordStrokes.map { stroke in
EdgeRefinement.refineSelectionToEdges( EdgeRefinement.refineSelectionToEdges(
@@ -200,24 +207,17 @@ struct BrushCanvasView: View {
} }
} }
// Create mask image from strokes at the ACTUAL display image size
// (not the original image size, which may differ after undo/redo)
let format = UIGraphicsImageRendererFormat() let format = UIGraphicsImageRendererFormat()
format.scale = 1.0 // Don't use screen scale, use actual pixel size format.scale = 1.0
let renderer = UIGraphicsImageRenderer(size: actualImageSize, format: format) let renderer = UIGraphicsImageRenderer(size: actualImageSize, format: format)
let maskImage = renderer.image { ctx in let maskImage = renderer.image { ctx in
// Fill with black (not masked)
UIColor.black.setFill() UIColor.black.setFill()
ctx.fill(CGRect(origin: .zero, size: actualImageSize)) ctx.fill(CGRect(origin: .zero, size: actualImageSize))
// If we have a pending mask from Person/Object detection, draw it first
// Note: pendingMask might be at original size, so we draw it scaled
if let pendingMask = viewModel.pendingRefineMask { if let pendingMask = viewModel.pendingRefineMask {
// Draw the pending mask - white areas become part of the combined mask
ctx.cgContext.draw(pendingMask, in: CGRect(origin: .zero, size: actualImageSize)) ctx.cgContext.draw(pendingMask, in: CGRect(origin: .zero, size: actualImageSize))
} }
// Draw brush strokes in white (masked areas) on top
UIColor.white.setStroke() UIColor.white.setStroke()
for stroke in imageCoordStrokes { for stroke in imageCoordStrokes {
@@ -239,7 +239,6 @@ struct BrushCanvasView: View {
if let cgImage = maskImage.cgImage { if let cgImage = maskImage.cgImage {
DebugLogger.imageInfo("Created brush mask", image: cgImage) DebugLogger.imageInfo("Created brush mask", image: cgImage)
// Clear the pending mask since we've incorporated it
viewModel.pendingRefineMask = nil viewModel.pendingRefineMask = nil
viewModel.maskPreview = nil viewModel.maskPreview = nil
await viewModel.applyBrushMask(cgImage) await viewModel.applyBrushMask(cgImage)
@@ -247,17 +246,281 @@ struct BrushCanvasView: View {
DebugLogger.error("Failed to create CGImage from brush mask") DebugLogger.error("Failed to create CGImage from brush mask")
} }
// Clear strokes after applying
allStrokes.removeAll() allStrokes.removeAll()
DebugLogger.log("Strokes cleared") DebugLogger.log("Strokes cleared")
} }
} }
// MARK: - UIKit Gesture Handler
/// A UIViewRepresentable that handles touch gestures with proper single vs two-finger differentiation.
/// - Single finger: Drawing brush strokes
/// - Two fingers: Pan (when zoomed) and pinch to zoom
struct BrushGestureView: UIViewRepresentable {
let displayedImageFrame: CGRect
@Binding var scale: CGFloat
@Binding var lastScale: CGFloat
@Binding var offset: CGSize
@Binding var lastOffset: CGSize
let minScale: CGFloat
let maxScale: CGFloat
@Binding var currentStroke: [CGPoint]
@Binding var allStrokes: [[CGPoint]]
@Binding var currentTouchLocation: CGPoint?
let clampOffset: () -> Void
func makeUIView(context: Context) -> BrushGestureUIView {
let view = BrushGestureUIView()
view.backgroundColor = .clear
view.isMultipleTouchEnabled = true
view.coordinator = context.coordinator
return view
}
func updateUIView(_ uiView: BrushGestureUIView, context: Context) {
context.coordinator.displayedImageFrame = displayedImageFrame
context.coordinator.scale = $scale
context.coordinator.lastScale = $lastScale
context.coordinator.offset = $offset
context.coordinator.lastOffset = $lastOffset
context.coordinator.minScale = minScale
context.coordinator.maxScale = maxScale
context.coordinator.currentStroke = $currentStroke
context.coordinator.allStrokes = $allStrokes
context.coordinator.currentTouchLocation = $currentTouchLocation
context.coordinator.clampOffset = clampOffset
}
func makeCoordinator() -> Coordinator {
Coordinator(
displayedImageFrame: displayedImageFrame,
scale: $scale,
lastScale: $lastScale,
offset: $offset,
lastOffset: $lastOffset,
minScale: minScale,
maxScale: maxScale,
currentStroke: $currentStroke,
allStrokes: $allStrokes,
currentTouchLocation: $currentTouchLocation,
clampOffset: clampOffset
)
}
class Coordinator: NSObject {
var displayedImageFrame: CGRect
var scale: Binding<CGFloat>
var lastScale: Binding<CGFloat>
var offset: Binding<CGSize>
var lastOffset: Binding<CGSize>
var minScale: CGFloat
var maxScale: CGFloat
var currentStroke: Binding<[CGPoint]>
var allStrokes: Binding<[[CGPoint]]>
var currentTouchLocation: Binding<CGPoint?>
var clampOffset: () -> Void
// Track if we're in drawing mode (single finger) or navigation mode (two fingers)
var isDrawing = false
var initialPinchScale: CGFloat = 1.0
init(
displayedImageFrame: CGRect,
scale: Binding<CGFloat>,
lastScale: Binding<CGFloat>,
offset: Binding<CGSize>,
lastOffset: Binding<CGSize>,
minScale: CGFloat,
maxScale: CGFloat,
currentStroke: Binding<[CGPoint]>,
allStrokes: Binding<[[CGPoint]]>,
currentTouchLocation: Binding<CGPoint?>,
clampOffset: @escaping () -> Void
) {
self.displayedImageFrame = displayedImageFrame
self.scale = scale
self.lastScale = lastScale
self.offset = offset
self.lastOffset = lastOffset
self.minScale = minScale
self.maxScale = maxScale
self.currentStroke = currentStroke
self.allStrokes = allStrokes
self.currentTouchLocation = currentTouchLocation
self.clampOffset = clampOffset
}
}
}
/// Custom UIView that handles touch events directly for precise control over single vs multi-touch.
class BrushGestureUIView: UIView {
var coordinator: BrushGestureView.Coordinator?
// Track active touches ourselves for reliability
private var activeTouches: [UITouch] = []
private var isDrawing = false
private var isNavigating = false
private var initialPinchDistance: CGFloat = 0
private var initialPinchScale: CGFloat = 1.0
private var lastPanLocation: CGPoint = .zero
private func activeTouchCount() -> Int {
return activeTouches.count
}
private func getActiveTouchLocations() -> [CGPoint] {
return activeTouches.map { $0.location(in: self) }
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let coordinator = coordinator else { return }
// Add new touches to our tracking
for touch in touches {
if !activeTouches.contains(touch) {
activeTouches.append(touch)
}
}
let touchCount = activeTouchCount()
if touchCount == 1 {
// Single finger - start drawing
let point = activeTouches[0].location(in: self)
isDrawing = true
isNavigating = false
coordinator.currentTouchLocation.wrappedValue = point
if coordinator.displayedImageFrame.contains(point) {
coordinator.currentStroke.wrappedValue.append(point)
}
} else if touchCount >= 2 {
// Two or more fingers - switch to navigation mode
if isDrawing {
// Was drawing, cancel current stroke
coordinator.currentStroke.wrappedValue.removeAll()
coordinator.currentTouchLocation.wrappedValue = nil
isDrawing = false
}
// Start navigation
let locations = getActiveTouchLocations()
let p1 = locations[0]
let p2 = locations[1]
initialPinchDistance = hypot(p2.x - p1.x, p2.y - p1.y)
initialPinchScale = coordinator.scale.wrappedValue
lastPanLocation = CGPoint(x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2)
isNavigating = true
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let coordinator = coordinator else { return }
let touchCount = activeTouchCount()
if touchCount == 1 && isDrawing {
// Single finger - continue drawing
let point = activeTouches[0].location(in: self)
coordinator.currentTouchLocation.wrappedValue = point
if coordinator.displayedImageFrame.contains(point) {
coordinator.currentStroke.wrappedValue.append(point)
}
} else if touchCount >= 2 && isNavigating {
// Two fingers - pan and zoom
let locations = getActiveTouchLocations()
let p1 = locations[0]
let p2 = locations[1]
// Handle pinch zoom
if initialPinchDistance > 0 {
let currentDistance = hypot(p2.x - p1.x, p2.y - p1.y)
let scaleFactor = currentDistance / initialPinchDistance
let newScale = initialPinchScale * scaleFactor
coordinator.scale.wrappedValue = min(max(newScale, coordinator.minScale), coordinator.maxScale)
}
// Handle pan
let currentCenter = CGPoint(x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2)
let deltaX = currentCenter.x - lastPanLocation.x
let deltaY = currentCenter.y - lastPanLocation.y
coordinator.offset.wrappedValue = CGSize(
width: coordinator.offset.wrappedValue.width + deltaX,
height: coordinator.offset.wrappedValue.height + deltaY
)
lastPanLocation = currentCenter
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let coordinator = coordinator else { return }
// Remove ended touches from tracking
activeTouches.removeAll { touches.contains($0) }
let remainingCount = activeTouchCount()
if isDrawing && remainingCount == 0 {
// Finish drawing stroke
coordinator.currentTouchLocation.wrappedValue = nil
if !coordinator.currentStroke.wrappedValue.isEmpty {
coordinator.allStrokes.wrappedValue.append(coordinator.currentStroke.wrappedValue)
coordinator.currentStroke.wrappedValue = []
}
isDrawing = false
}
if isNavigating && remainingCount < 2 {
// End navigation
coordinator.lastScale.wrappedValue = coordinator.scale.wrappedValue
coordinator.lastOffset.wrappedValue = coordinator.offset.wrappedValue
withAnimation(.spring(duration: 0.3)) {
coordinator.clampOffset()
}
isNavigating = false
// Don't start drawing with remaining finger - wait for fresh touch
}
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let coordinator = coordinator else { return }
// Remove cancelled touches
activeTouches.removeAll { touches.contains($0) }
// Cancel any ongoing gesture
coordinator.currentTouchLocation.wrappedValue = nil
coordinator.currentStroke.wrappedValue.removeAll()
if isNavigating {
coordinator.lastScale.wrappedValue = coordinator.scale.wrappedValue
coordinator.lastOffset.wrappedValue = coordinator.offset.wrappedValue
coordinator.clampOffset()
}
isDrawing = false
isNavigating = false
}
}
#Preview { #Preview {
@Previewable @State var scale: CGFloat = 1.0
@Previewable @State var lastScale: CGFloat = 1.0
@Previewable @State var offset: CGSize = .zero
@Previewable @State var lastOffset: CGSize = .zero
let viewModel = EditorViewModel() let viewModel = EditorViewModel()
return BrushCanvasView( return BrushCanvasView(
viewModel: viewModel, viewModel: viewModel,
imageSize: CGSize(width: 1000, height: 1000), imageSize: CGSize(width: 1000, height: 1000),
displayedImageFrame: CGRect(x: 0, y: 0, width: 300, height: 300) displayedImageFrame: CGRect(x: 0, y: 0, width: 300, height: 300),
scale: $scale,
lastScale: $lastScale,
offset: $offset,
lastOffset: $lastOffset,
minScale: 1.0,
maxScale: 10.0,
clampOffset: {}
) )
} }

View File

@@ -51,20 +51,28 @@ struct CanvasView: View {
BrushCanvasView( BrushCanvasView(
viewModel: viewModel, viewModel: viewModel,
imageSize: viewModel.imageSize, imageSize: viewModel.imageSize,
displayedImageFrame: displayedImageFrame(in: geometry.size) displayedImageFrame: displayedImageFrame(in: geometry.size),
scale: $scale,
lastScale: $lastScale,
offset: $offset,
lastOffset: $lastOffset,
minScale: minScale,
maxScale: maxScale,
clampOffset: { clampOffset(in: geometry.size) }
) )
} }
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.gesture(tapGesture(in: geometry)) .applyGestures(
.gesture(magnificationGesture(in: geometry)) isBrushMode: viewModel.selectedTool == .brush,
.simultaneousGesture(combinedDragGesture(in: geometry)) tapGesture: tapGesture(in: geometry),
.simultaneousGesture(longPressGesture) magnificationGesture: magnificationGesture(in: geometry),
.onTapGesture(count: 2) { dragGesture: combinedDragGesture(in: geometry),
doubleTapZoom() longPressGesture: longPressGesture,
} doubleTapAction: doubleTapZoom
)
.onAppear { .onAppear {
viewSize = geometry.size viewSize = geometry.size
} }
@@ -155,8 +163,8 @@ struct CanvasView: View {
private func combinedDragGesture(in geometry: GeometryProxy) -> some Gesture { private func combinedDragGesture(in geometry: GeometryProxy) -> some Gesture {
DragGesture(minimumDistance: 1) DragGesture(minimumDistance: 1)
.onChanged { value in .onChanged { value in
// Only allow panning when move tool is selected // Allow panning for person, object, and move tools (not brush - uses two-finger pan)
guard viewModel.selectedTool == .move else { return } guard viewModel.selectedTool == .person || viewModel.selectedTool == .object || viewModel.selectedTool == .move else { return }
// Pan mode: only when zoomed in // Pan mode: only when zoomed in
if scale > 1.0 { if scale > 1.0 {
@@ -167,8 +175,8 @@ struct CanvasView: View {
} }
} }
.onEnded { _ in .onEnded { _ in
// Only allow panning when move tool is selected // Allow panning for person, object, and move tools (not brush - uses two-finger pan)
guard viewModel.selectedTool == .move else { return } guard viewModel.selectedTool == .person || viewModel.selectedTool == .object || viewModel.selectedTool == .move else { return }
lastOffset = offset lastOffset = offset
withAnimation(.spring(duration: 0.3)) { withAnimation(.spring(duration: 0.3)) {
@@ -180,15 +188,14 @@ struct CanvasView: View {
private func magnificationGesture(in geometry: GeometryProxy) -> some Gesture { private func magnificationGesture(in geometry: GeometryProxy) -> some Gesture {
MagnificationGesture() MagnificationGesture()
.onChanged { value in .onChanged { value in
// Only allow zooming when move tool is selected // Allow zooming for person, object, and move tools (brush uses UIKit pinch gesture)
guard viewModel.selectedTool == .move else { return } guard viewModel.selectedTool != .brush else { return }
let newScale = lastScale * value let newScale = lastScale * value
scale = min(max(newScale, minScale), maxScale) scale = min(max(newScale, minScale), maxScale)
} }
.onEnded { _ in .onEnded { _ in
// Only allow zooming when move tool is selected guard viewModel.selectedTool != .brush else { return }
guard viewModel.selectedTool == .move else { return }
lastScale = scale lastScale = scale
withAnimation(.spring(duration: 0.3)) { withAnimation(.spring(duration: 0.3)) {
@@ -210,9 +217,7 @@ struct CanvasView: View {
} }
private func doubleTapZoom() { private func doubleTapZoom() {
// Only allow double-tap zoom when move tool is selected // Allow double-tap zoom for all tools
guard viewModel.selectedTool == .move else { return }
withAnimation(.spring(duration: 0.3)) { withAnimation(.spring(duration: 0.3)) {
if scale > 1.0 { if scale > 1.0 {
scale = 1.0 scale = 1.0
@@ -312,6 +317,37 @@ struct CanvasView: View {
} }
} }
// MARK: - Conditional Gesture Application
extension View {
/// Applies gestures conditionally based on whether brush mode is active.
/// In brush mode, all SwiftUI gestures are disabled to allow UIKit touch handling in BrushCanvasView.
@ViewBuilder
func applyGestures<T: Gesture, M: Gesture, D: Gesture, L: Gesture>(
isBrushMode: Bool,
tapGesture: T,
magnificationGesture: M,
dragGesture: D,
longPressGesture: L,
doubleTapAction: @escaping () -> Void
) -> some View {
if isBrushMode {
// In brush mode, don't attach any gestures - let BrushCanvasView handle everything
self
} else {
// In other modes, attach all gestures
self
.gesture(tapGesture)
.gesture(magnificationGesture)
.simultaneousGesture(dragGesture)
.simultaneousGesture(longPressGesture)
.onTapGesture(count: 2) {
doubleTapAction()
}
}
}
}
#Preview { #Preview {
let viewModel = EditorViewModel() let viewModel = EditorViewModel()
return CanvasView(viewModel: viewModel) return CanvasView(viewModel: viewModel)

BIN
screenshots/iPad/1.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
screenshots/iPad/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
screenshots/iPad/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
screenshots/iPhone/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

BIN
screenshots/iPhone/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

BIN
screenshots/iPhone/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 KiB

BIN
screenshots/iPhone/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
screenshots/iPhone/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
screenshots/iPhone/6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB