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