From ce89d3465448e65596cd48aba19197f1318ad3ec Mon Sep 17 00:00:00 2001 From: TZGyn Date: Mon, 26 Oct 2026 20:45:48 +0800 Subject: [PATCH] Make crop a Photos-style tool with undo and actual-size view Crop is now a mode like Draw/Redact: a draggable rect with corner and edge handles, rule-of-thirds grid, dimmed surroundings, Cancel (Esc) and Crop Image (Return). Re-entering crop after a crop shows the full original with the handles on the current crop region, so it can be dragged back outward; every crop is undoable from the toolbar. The result window also gains an Actual Size view (Cmd+0): one image pixel per screen pixel, scrollable, so retina captures stay crisp instead of being resampled down to the window size. Generated with OpenCode Assisted-by: Kimi:k3 --- Sources/Kapture/ResultWindow.swift | 413 +++++++++++++++++++++++++---- build-app.sh | 4 +- 2 files changed, 361 insertions(+), 56 deletions(-) diff --git a/Sources/Kapture/ResultWindow.swift b/Sources/Kapture/ResultWindow.swift index 9fad9d3..3996c26 100644 --- a/Sources/Kapture/ResultWindow.swift +++ b/Sources/Kapture/ResultWindow.swift @@ -90,12 +90,24 @@ final class CaptureModel: ObservableObject { @Published var statusMessage: String? /// Drag selection rect in IMAGE point space (nil = none) @Published var selection: CGRect? + /// Crop rect while the crop tool is active, in the DISPLAYED image's + /// pixel space (the original image once a crop exists). + @Published var cropRect: CGRect? + /// The uncropped original image — set on the first crop so crop mode can + /// show it and the crop can be adjusted back outward (like Photos). + @Published private(set) var originalImage: CGImage? + /// Origin of the current image within the original image (pixels). + @Published private(set) var cropOffset: CGPoint = .zero /// Freehand drawing strokes in IMAGE point space @Published var strokes: [Stroke] = [] /// Redo stack of removed strokes @Published var redoStack: [Stroke] = [] + /// Undo stack of pre-crop states (image + annotations + offset) + @Published private(set) var cropHistory: [(image: CGImage, strokes: [Stroke], redoStack: [Stroke], offset: CGPoint)] = [] /// Whether the pen is active (drag draws instead of selecting) @Published var tool: Tool = .select + /// Actual-size view: 1 image pixel per screen pixel, scrollable. + @Published var isActualSize = false /// Current drawing color @Published var drawColor: NSColor = .systemRed /// Stroke thickness in points (image space) @@ -105,6 +117,7 @@ final class CaptureModel: ObservableObject { case select case draw case redact + case crop } struct Stroke { @@ -126,6 +139,53 @@ final class CaptureModel: ObservableObject { var hasSelection: Bool { selection != nil && selection!.width >= 2 && selection!.height >= 2 } var canUndo: Bool { !strokes.isEmpty } var canRedo: Bool { !redoStack.isEmpty } + var canUndoCrop: Bool { !cropHistory.isEmpty } + + /// The image shown in crop mode: the original once a crop exists, so the + /// handles sit on the cropped region and can be dragged back outward. + var cropModeImage: CGImage { originalImage ?? image } + + /// Enter crop mode: start from the current selection or the current + /// crop region, mapped into the displayed image's space. + func beginCropMode() { + if hasSelection { + // The selection is in current-image space; map it into the + // displayed (original) space. + cropRect = selection!.offsetBy(dx: cropOffset.x, dy: cropOffset.y) + selection = nil + } else { + cropRect = originalImage != nil + ? CGRect(origin: cropOffset, size: CGSize(width: image.width, height: image.height)) + : CGRect(origin: .zero, size: CGSize(width: image.width, height: image.height)) + } + } + + func cancelCrop() { + cropRect = nil + if tool == .crop { tool = .select } + } + + /// Apply the pending crop rect and leave crop mode. + func applyCrop() { + guard let rect = cropRect else { return } + cropRect = nil + tool = .select + crop(to: rect) + } + + /// Restore the image and annotations from before the most recent crop. + func undoCrop() { + guard let snapshot = cropHistory.popLast() else { return } + image = snapshot.image + strokes = snapshot.strokes + redoStack = snapshot.redoStack + cropOffset = snapshot.offset + selection = nil + if tool == .crop { + cropRect = CGRect(origin: cropOffset, size: CGSize(width: image.width, height: image.height)) + } + statusMessage = "Crop undone." + } func undoStroke() { guard let last = strokes.popLast() else { return } @@ -215,18 +275,29 @@ final class CaptureModel: ObservableObject { return extractedText ?? "Select text in the screenshot, then click “Get Text”." } - /// Crop the image to `rectInImage` (image pixel space). Strokes are - /// translated into the new coordinate space; strokes fully outside the - /// crop are dropped. Selection is cleared. - func crop(to rectInImage: CGRect) { - let bounds = CGRect(origin: .zero, size: CGSize(width: image.width, height: image.height)) - let rect = rectInImage.integral.intersection(bounds) - guard rect.width >= 2, rect.height >= 2, rect != bounds, - let cropped = image.cropping(to: rect) else { return } + /// Crop to `rect` in the DISPLAYED image's pixel space (the original + /// image once a crop exists). Strokes are translated into the new + /// coordinate space; strokes fully outside the crop are dropped. The + /// previous state is pushed onto the crop undo stack. + func crop(to rectInDisplayedSpace: CGRect) { + let source = originalImage ?? image + let bounds = CGRect(origin: .zero, size: CGSize(width: source.width, height: source.height)) + let rect = rectInDisplayedSpace.integral.intersection(bounds) + // No-op when the crop matches what's already visible. + let currentVisible = CGRect(origin: cropOffset, size: CGSize(width: image.width, height: image.height)) + guard rect.width >= 2, rect.height >= 2, rect != currentVisible, + let cropped = source.cropping(to: rect) else { return } + cropHistory.append((image: image, strokes: strokes, redoStack: redoStack, offset: cropOffset)) image = cropped + if originalImage == nil { originalImage = source } + // Strokes live in current-image space: a point p maps into the + // original as p + old offset, then into the new crop as -rect.origin. + let dx = cropOffset.x - rect.minX + let dy = cropOffset.y - rect.minY + cropOffset = rect.origin let newBounds = CGRect(origin: .zero, size: rect.size) func translated(_ stroke: Stroke) -> Stroke? { - let points = stroke.points.map { CGPoint(x: $0.x - rect.minX, y: $0.y - rect.minY) } + let points = stroke.points.map { CGPoint(x: $0.x + dx, y: $0.y + dy) } let xs = points.map { $0.x } let ys = points.map { $0.y } let strokeBounds = CGRect( @@ -242,11 +313,6 @@ final class CaptureModel: ObservableObject { statusMessage = "Cropped to \(Int(rect.width))×\(Int(rect.height))." } - func cropToSelection() { - guard let sel = selection, hasSelection else { return } - crop(to: sel) - } - func runOCR(over selectionOnly: CGRect? = nil) { guard !isOCRRunning else { return } isOCRRunning = true @@ -409,6 +475,7 @@ private struct CapCapsuleButton: View { var primary = false var disabled = false var help: String + var shortcut: KeyboardShortcut? = nil let action: () -> Void var body: some View { @@ -422,6 +489,7 @@ private struct CapCapsuleButton: View { .contentShape(Capsule()) } .buttonStyle(CapPressableStyle()) + .keyboardShortcut(shortcut) .disabled(disabled) .opacity(disabled ? 0.5 : 1) .help(help) @@ -469,6 +537,20 @@ struct ResultView: View { HStack(spacing: 6) { CapModePicker(mode: $model.mode) + CapIconButton( + icon: "1.magnifyingglass", + active: model.isActualSize, + help: "Actual size — 1 image pixel per screen pixel (⌘0)", + shortcut: KeyboardShortcut("0", modifiers: .command) + ) { model.isActualSize.toggle() } + + if model.canUndoCrop { + CapIconButton( + icon: "arrow.uturn.backward", + help: "Undo the last crop" + ) { model.undoCrop() } + } + Spacer() if model.hasSelection { @@ -477,11 +559,6 @@ struct ResultView: View { primary: true, disabled: model.isOCRRunning, help: "Recognize text inside the selected region" ) { model.runOCRSelection() } - - CapCapsuleButton( - title: "Crop", icon: "crop", - help: "Crop the image to the selected region" - ) { model.cropToSelection() } } CapCapsuleButton( @@ -497,6 +574,7 @@ struct ResultView: View { ) { model.tool = model.tool == .draw ? .select : .draw if model.tool != .select { model.selection = nil } + model.cropRect = nil } CapIconButton( @@ -506,6 +584,20 @@ struct ResultView: View { ) { model.tool = model.tool == .redact ? .select : .redact if model.tool != .select { model.selection = nil } + model.cropRect = nil + } + + CapIconButton( + icon: "crop", + active: model.tool == .crop, + help: "Crop the image" + ) { + if model.tool == .crop { + model.cancelCrop() + } else { + model.tool = .crop + model.beginCropMode() + } } CapIconButton(icon: "doc.on.doc", help: "Copy image to clipboard") { model.copyImage() } @@ -518,9 +610,12 @@ struct ResultView: View { // Tool options (only when a draw tool is active) if model.tool != .select { HStack(spacing: 14) { - Label(model.tool == .redact ? "Redact" : "Draw", systemImage: model.tool == .redact ? "rectangle.fill" : "pencil.tip") - .font(.caption) - .foregroundStyle(.secondary) + Label( + model.tool == .redact ? "Redact" : model.tool == .crop ? "Crop" : "Draw", + systemImage: model.tool == .redact ? "rectangle.fill" : model.tool == .crop ? "crop" : "pencil.tip" + ) + .font(.caption) + .foregroundStyle(.secondary) if model.tool == .draw { HStack(spacing: 6) { @@ -541,30 +636,49 @@ struct ResultView: View { .labelsHidden() .frame(width: 28) .help("Drawing color") - } else { + } else if model.tool == .redact { Label("Drag to cover sensitive text", systemImage: "eye.slash") .font(.caption) .foregroundStyle(.secondary) + } else { + Label("Drag the corners or edges to adjust the crop", systemImage: "hand.draw") + .font(.caption) + .foregroundStyle(.secondary) } Spacer() - CapIconButton( - icon: "arrow.uturn.backward", disabled: !model.canUndo, - help: "Undo last stroke (⌘Z)", - shortcut: KeyboardShortcut("z", modifiers: .command) - ) { model.undoStroke() } + if model.tool == .crop { + CapCapsuleButton( + title: "Cancel", icon: "xmark", + help: "Cancel cropping (Esc)", + shortcut: .cancelAction + ) { model.cancelCrop() } - CapIconButton( - icon: "arrow.uturn.forward", disabled: !model.canRedo, - help: "Redo stroke (⌘⇧Z)", - shortcut: KeyboardShortcut("z", modifiers: [.command, .shift]) - ) { model.redoStroke() } + CapCapsuleButton( + title: "Crop Image", icon: "checkmark", + primary: true, + help: "Apply the crop (Return)", + shortcut: .defaultAction + ) { model.applyCrop() } + } else { + CapIconButton( + icon: "arrow.uturn.backward", disabled: !model.canUndo, + help: "Undo last stroke (⌘Z)", + shortcut: KeyboardShortcut("z", modifiers: .command) + ) { model.undoStroke() } - CapIconButton( - icon: "eraser", disabled: model.strokes.isEmpty, - help: "Remove all drawn annotations" - ) { model.clearAnnotations() } + CapIconButton( + icon: "arrow.uturn.forward", disabled: !model.canRedo, + help: "Redo stroke (⌘⇧Z)", + shortcut: KeyboardShortcut("z", modifiers: [.command, .shift]) + ) { model.redoStroke() } + + CapIconButton( + icon: "eraser", disabled: model.strokes.isEmpty, + help: "Remove all drawn annotations" + ) { model.clearAnnotations() } + } } .padding(.horizontal, 12) .padding(.vertical, 6) @@ -596,7 +710,11 @@ struct ResultView: View { .controlSize(.small) } Text(model.statusMessage ?? (model.mode == .screenshot - ? (model.hasSelection ? "Region selected — OCR it or Crop." : "Drag to select a region, or Get All Text.") + ? (model.tool == .crop + ? "Adjust the crop — Return to apply, Esc to cancel." + : (model.hasSelection + ? "Region selected — OCR it, or adjust it with Crop." + : (model.isActualSize ? "Actual size — scroll to pan, drag to select." : "Drag to select a region, or Get All Text."))) : "Selectable text")) .font(.caption) .foregroundStyle(.secondary) @@ -622,8 +740,20 @@ struct ResultView: View { struct ScreenshotPane: View { @ObservedObject var model: CaptureModel + /// In crop mode after a crop: the original image is displayed so the + /// crop handles sit on the cropped region and can be dragged back out. + private var displayedImage: CGImage { + model.tool == .crop ? model.cropModeImage : model.image + } + + /// Strokes are stored in current-image space; when the original is + /// displayed in crop mode they shift by the cumulative crop offset. + private var strokeDisplayOffset: CGPoint { + model.tool == .crop ? model.cropOffset : .zero + } + private var imageSize: CGSize { - CGSize(width: model.image.width, height: model.image.height) + CGSize(width: displayedImage.width, height: displayedImage.height) } private func geometry(for geo: GeometryProxy) -> (scale: CGFloat, drawSize: CGSize, origin: CGPoint) { @@ -649,14 +779,136 @@ struct ScreenshotPane: View { } @State private var currentStroke: [CGPoint] = [] + @State private var cropDrag: CropDrag? + @State private var cropStartRect = CGRect.zero + + /// Which part of the crop rect a drag is manipulating. + private enum CropDrag { + case move, n, s, e, w, ne, nw, se, sw + } + + /// Hit-test the drag start against the crop handles (corners, edge + /// midpoints), then the interior (move). Nil = outside the crop rect. + private func cropHandle(at viewPoint: CGPoint, cropView: CGRect) -> CropDrag? { + let points: [(CropDrag, CGPoint)] = [ + (.nw, CGPoint(x: cropView.minX, y: cropView.minY)), + (.n, CGPoint(x: cropView.midX, y: cropView.minY)), + (.ne, CGPoint(x: cropView.maxX, y: cropView.minY)), + (.e, CGPoint(x: cropView.maxX, y: cropView.midY)), + (.se, CGPoint(x: cropView.maxX, y: cropView.maxY)), + (.s, CGPoint(x: cropView.midX, y: cropView.maxY)), + (.sw, CGPoint(x: cropView.minX, y: cropView.maxY)), + (.w, CGPoint(x: cropView.minX, y: cropView.midY)), + ] + for (drag, point) in points where hypot(point.x - viewPoint.x, point.y - viewPoint.y) <= 16 { + return drag + } + return cropView.contains(viewPoint) ? .move : nil + } + + /// Compute the crop rect for a drag, clamped to the image and with a + /// minimum size. The dragged edge/corner follows the pointer; the + /// opposite side stays put. + private static func resizedCropRect(from start: CGRect, drag: CropDrag, delta: CGSize, bounds: CGRect) -> CGRect { + let minSize: CGFloat = 10 + switch drag { + case .move: + var rect = start.offsetBy(dx: delta.width, dy: delta.height) + rect.origin.x = min(max(rect.origin.x, bounds.minX), bounds.maxX - rect.width) + rect.origin.y = min(max(rect.origin.y, bounds.minY), bounds.maxY - rect.height) + return rect + case .n, .s, .e, .w: + var minX = start.minX, maxX = start.maxX + var minY = start.minY, maxY = start.maxY + if drag == .w { minX = min(max(start.minX + delta.width, bounds.minX), start.maxX - minSize) } + if drag == .e { maxX = max(min(start.maxX + delta.width, bounds.maxX), start.minX + minSize) } + if drag == .n { minY = min(max(start.minY + delta.height, bounds.minY), start.maxY - minSize) } + if drag == .s { maxY = max(min(start.maxY + delta.height, bounds.maxY), start.minY + minSize) } + return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + case .nw, .ne, .sw, .se: + // Corner drag: the opposite corner is the anchor; normalize so + // the anchor stays fixed whichever way the pointer goes. + let anchor = CGPoint( + x: (drag == .nw || drag == .sw) ? start.maxX : start.minX, + y: (drag == .nw || drag == .ne) ? start.maxY : start.minY + ) + let moved = CGPoint( + x: min(max(((drag == .nw || drag == .sw) ? start.minX : start.maxX) + delta.width, bounds.minX), bounds.maxX), + y: min(max(((drag == .nw || drag == .ne) ? start.minY : start.maxY) + delta.height, bounds.minY), bounds.maxY) + ) + let rect = CGRect( + x: min(anchor.x, moved.x), y: min(anchor.y, moved.y), + width: abs(moved.x - anchor.x), height: abs(moved.y - anchor.y) + ) + return (rect.width >= minSize && rect.height >= minSize) ? rect : start + } + } + + private func updateCropDrag(_ value: DragGesture.Value, scale: CGFloat, origin: CGPoint) { + guard let current = model.cropRect else { return } + let bounds = CGRect(origin: .zero, size: imageSize) + if cropDrag == nil { + let cropView = CGRect( + x: origin.x + current.minX * scale, y: origin.y + current.minY * scale, + width: current.width * scale, height: current.height * scale + ) + if let handle = cropHandle(at: value.startLocation, cropView: cropView) { + cropDrag = handle + cropStartRect = current + } else { + // Started outside the crop rect: draw a fresh one anchored + // where the drag began (handled by the .se anchor math). + let anchor = toImagePoint(value.startLocation, scale: scale, origin: origin) + guard bounds.contains(anchor) else { return } + cropDrag = .se + cropStartRect = CGRect(origin: anchor, size: .zero) + } + } + guard let drag = cropDrag else { return } + let delta = CGSize(width: value.translation.width / scale, height: value.translation.height / scale) + let newRect = Self.resizedCropRect(from: cropStartRect, drag: drag, delta: delta, bounds: bounds) + guard newRect.width >= 1, newRect.height >= 1 else { return } + model.cropRect = newRect + } + + @Environment(\.displayScale) private var displayScale var body: some View { GeometryReader { geo in - let (scale, drawSize, origin) = geometry(for: geo) - let cardRect = CGRect(origin: origin, size: drawSize) - let cardClip = RoundedRectangle(cornerRadius: 10).path(in: cardRect) + if model.isActualSize { + // Actual size: 1 image pixel per screen pixel (view scale = + // 1/displayScale). Scrollable when larger than the viewport; + // centred when smaller. + let scale = 1 / max(displayScale, 0.01) + let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale) + let origin = CGPoint( + x: max(0, (geo.size.width - drawSize.width) / 2), + y: max(0, (geo.size.height - drawSize.height) / 2) + ) + ScrollView([.horizontal, .vertical]) { + imageContent(scale: scale, drawSize: drawSize, origin: origin) + .frame( + width: max(drawSize.width, geo.size.width), + height: max(drawSize.height, geo.size.height) + ) + } + } else { + let (scale, drawSize, origin) = geometry(for: geo) + imageContent(scale: scale, drawSize: drawSize, origin: origin) + .frame(width: geo.size.width, height: geo.size.height) + } + } + } - ZStack { + /// The image card with annotations, selection and crop overlays, plus + /// the drag gesture. Parameterized by scale/origin so it works both + /// fit-to-window and at actual size inside the ScrollView. + private func imageContent(scale: CGFloat, drawSize: CGSize, origin: CGPoint) -> some View { + let cardRect = CGRect(origin: origin, size: drawSize) + let cardClip = RoundedRectangle(cornerRadius: 10).path(in: cardRect) + let strokeOffset = strokeDisplayOffset + + return ZStack { // Card behind the image RoundedRectangle(cornerRadius: 10) .fill(CapStyle.card) @@ -664,9 +916,9 @@ struct ScreenshotPane: View { .frame(width: drawSize.width, height: drawSize.height) .position(x: cardRect.midX, y: cardRect.midY) - Image(nsImage: NSImage(cgImage: model.image, size: .zero)) + Image(nsImage: NSImage(cgImage: displayedImage, size: .zero)) .resizable() - .interpolation(imageSize.width >= geo.size.width ? .high : .none) + .interpolation(scale < 1 ? .high : .none) .aspectRatio(contentMode: .fit) .frame(width: drawSize.width, height: drawSize.height) .clipShape(RoundedRectangle(cornerRadius: 10)) @@ -680,17 +932,17 @@ struct ScreenshotPane: View { let xs = stroke.points.map { $0.x } let ys = stroke.points.map { $0.y } let bar = CGRect( - x: origin.x + (xs.min()! - 2) * scale, - y: origin.y + (ys.min()! - 2) * scale, + x: origin.x + (xs.min()! + strokeOffset.x - 2) * scale, + y: origin.y + (ys.min()! + strokeOffset.y - 2) * scale, width: ((xs.max()! - xs.min()!) + 4) * scale, height: (max(stroke.width, ys.max()! - ys.min()!) + 4) * scale ) context.fill(Path(bar), with: .color(.black)) } else { var path = Path() - path.move(to: CGPoint(x: origin.x + stroke.points[0].x * scale, y: origin.y + stroke.points[0].y * scale)) + path.move(to: CGPoint(x: origin.x + (stroke.points[0].x + strokeOffset.x) * scale, y: origin.y + (stroke.points[0].y + strokeOffset.y) * scale)) for point in stroke.points.dropFirst() { - path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale)) + path.addLine(to: CGPoint(x: origin.x + (point.x + strokeOffset.x) * scale, y: origin.y + (point.y + strokeOffset.y) * scale)) } // stroke.width is in image pixels; scale to view points context.stroke(path, with: .color(Color(nsColor: stroke.color)), lineWidth: stroke.width * scale) @@ -705,9 +957,9 @@ struct ScreenshotPane: View { Canvas { context, _ in var path = Path() if currentStroke.count > 1 { - path.move(to: CGPoint(x: origin.x + currentStroke[0].x * scale, y: origin.y + currentStroke[0].y * scale)) + path.move(to: CGPoint(x: origin.x + (currentStroke[0].x + strokeOffset.x) * scale, y: origin.y + (currentStroke[0].y + strokeOffset.y) * scale)) for point in currentStroke.dropFirst() { - path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale)) + path.addLine(to: CGPoint(x: origin.x + (point.x + strokeOffset.x) * scale, y: origin.y + (point.y + strokeOffset.y) * scale)) } } context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: model.strokeWidth) @@ -723,6 +975,56 @@ struct ScreenshotPane: View { .position(x: cardRect.midX, y: cardRect.midY) .allowsHitTesting(false) + if model.tool == .crop, let crop = model.cropRect { + let cropView = CGRect( + x: origin.x + crop.minX * scale, + y: origin.y + crop.minY * scale, + width: crop.width * scale, + height: crop.height * scale + ) + Canvas { context, size in + // Dim everything outside the crop rect. + var dim = Path() + dim.addRect(CGRect(origin: .zero, size: size)) + dim.addRect(cropView) + context.fill(dim, with: .color(.black.opacity(0.45)), style: FillStyle(eoFill: true)) + + // Rule-of-thirds grid. + var grid = Path() + for i in 1..<3 { + let x = cropView.minX + cropView.width * CGFloat(i) / 3 + grid.move(to: CGPoint(x: x, y: cropView.minY)) + grid.addLine(to: CGPoint(x: x, y: cropView.maxY)) + let y = cropView.minY + cropView.height * CGFloat(i) / 3 + grid.move(to: CGPoint(x: cropView.minX, y: y)) + grid.addLine(to: CGPoint(x: cropView.maxX, y: y)) + } + context.stroke(grid, with: .color(.white.opacity(0.35)), lineWidth: 1) + + // Border. + context.stroke(Path(cropView), with: .color(.white), lineWidth: 1.5) + + // Handles: white dots at corners and edge midpoints. + let handles: [CGPoint] = [ + CGPoint(x: cropView.minX, y: cropView.minY), + CGPoint(x: cropView.midX, y: cropView.minY), + CGPoint(x: cropView.maxX, y: cropView.minY), + CGPoint(x: cropView.maxX, y: cropView.midY), + CGPoint(x: cropView.maxX, y: cropView.maxY), + CGPoint(x: cropView.midX, y: cropView.maxY), + CGPoint(x: cropView.minX, y: cropView.maxY), + CGPoint(x: cropView.minX, y: cropView.midY), + ] + for point in handles { + let dot = Path(ellipseIn: CGRect(x: point.x - 4, y: point.y - 4, width: 8, height: 8)) + context.fill(dot, with: .color(.white)) + context.stroke(dot, with: .color(.black.opacity(0.4)), lineWidth: 1) + } + } + .clipShape(cardClip) + .allowsHitTesting(false) + } + if model.tool == .select, let sel = model.selection { let viewRect = CGRect( x: origin.x + sel.minX * scale, @@ -742,7 +1044,9 @@ struct ScreenshotPane: View { .gesture( DragGesture(minimumDistance: 0) .onChanged { value in - if model.tool != .select { + if model.tool == .crop { + updateCropDrag(value, scale: scale, origin: origin) + } else if model.tool != .select { let p = toImagePoint(value.location, scale: scale, origin: origin) let imgRect = CGRect(origin: .zero, size: imageSize) if imgRect.contains(p) { @@ -761,7 +1065,9 @@ struct ScreenshotPane: View { } } .onEnded { _ in - if model.tool != .select { + if model.tool == .crop { + cropDrag = nil + } else if model.tool != .select { if currentStroke.count > 1 { model.addStroke(currentStroke, widthPx: model.strokeWidth / max(scale, 0.01)) } @@ -773,6 +1079,5 @@ struct ScreenshotPane: View { } } ) - } } } diff --git a/build-app.sh b/build-app.sh index 50719a4..8499b0c 100755 --- a/build-app.sh +++ b/build-app.sh @@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' CFBundleIconFile Icon CFBundleShortVersionString - 1.11.0 + 1.11.1 CFBundleVersion - 110 + 111 LSUIElement NSHighResolutionCapable