Let crops extend past the image edges into blank padding

Crop handles can now be dragged outside the image; the extra area
applies as transparent padding (verified pixel-exact), capped at one
image dimension per side. Edges snap to the image borders within 10
points, and a Reset button in the crop bar restores the current image's
region. The crop canvas shows padding as a flat blank surface that
disappears as handles move back inside.

Generated with OpenCode

Assisted-by: Kimi:k3
This commit is contained in:
2026-10-31 20:22:49 +08:00
parent 6f8f436987
commit 6501aaefac
2 changed files with 107 additions and 25 deletions
+105 -23
View File
@@ -182,6 +182,12 @@ final class CaptureModel: ObservableObject {
if tool == .crop { tool = .select }
}
/// Reset the crop handles to the current image's region — undoes any
/// in-progress adjustment, including padding.
func resetCrop() {
cropRect = CGRect(origin: cropOffset, size: CGSize(width: image.width, height: image.height))
}
/// Apply the pending crop rect and leave crop mode.
func applyCrop() {
guard let rect = cropRect else { return }
@@ -293,17 +299,26 @@ final class CaptureModel: ObservableObject {
}
/// 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.
/// image once a crop exists). The rect may extend past the image: the
/// outside becomes transparent padding. 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)
let sourceBounds = CGRect(origin: .zero, size: CGSize(width: source.width, height: source.height))
let rect = rectInDisplayedSpace.integral
// 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 }
guard rect.width >= 2, rect.height >= 2, rect != currentVisible else { return }
let cropped: CGImage
if sourceBounds.contains(rect) {
guard let cut = source.cropping(to: rect) else { return }
cropped = cut
} else {
guard let padded = Self.paddedCrop(source: source, rect: rect) else { return }
cropped = padded
}
cropHistory.append((image: image, strokes: strokes, redoStack: redoStack, offset: cropOffset))
image = cropped
if originalImage == nil { originalImage = source }
@@ -330,6 +345,33 @@ final class CaptureModel: ObservableObject {
statusMessage = "Cropped to \(Int(rect.width))×\(Int(rect.height))."
}
/// Crop with padding: a transparent bitmap the size of the crop rect,
/// with the source image drawn at its offset — parts of the rect beyond
/// the source stay blank.
static func paddedCrop(source: CGImage, rect: CGRect) -> CGImage? {
let w = Int(rect.width), h = Int(rect.height)
guard w > 0, h > 0 else { return nil }
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: w, pixelsHigh: h,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
) else { return nil }
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
// This context is y-up and a CGImage's top edge lands on the
// destination rect's maxY. The source's top-left maps to the
// output's (-rect.minX, -rect.minY) in top-left space.
let ctx = NSGraphicsContext.current!.cgContext
ctx.draw(source, in: NSRect(
x: -rect.minX,
y: rect.height + rect.minY - CGFloat(source.height),
width: CGFloat(source.width),
height: CGFloat(source.height)
))
NSGraphicsContext.restoreGraphicsState()
return rep.cgImage
}
func runOCR(over selectionOnly: CGRect? = nil) {
guard !isOCRRunning else { return }
isOCRRunning = true
@@ -711,7 +753,7 @@ struct ResultView: View {
.font(.caption)
.foregroundStyle(.secondary)
} else {
Label("Drag the corners or edges to adjust the crop", systemImage: "hand.draw")
Label("Drag the corners or edges — past the edge for blank space", systemImage: "hand.draw")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -719,6 +761,11 @@ struct ResultView: View {
Spacer()
if model.tool == .crop {
CapCapsuleButton(
title: "Reset", icon: "arrow.counterclockwise",
help: "Reset the crop to the current image"
) { model.resetCrop() }
CapCapsuleButton(
title: "Cancel", icon: "xmark",
help: "Cancel cropping (Esc)",
@@ -914,9 +961,25 @@ struct ScreenshotPane: View {
}
}
/// Snap crop edges to the image borders when they get within
/// `threshold` pixels (view-scale adjusted by the caller).
private static func snapped(_ rect: CGRect, to bounds: CGRect, threshold: CGFloat) -> CGRect {
var r = rect
if abs(r.minX - bounds.minX) < threshold { r.origin.x = bounds.minX }
if abs(r.minY - bounds.minY) < threshold { r.origin.y = bounds.minY }
if abs(r.maxX - bounds.maxX) < threshold { r.origin.x = bounds.maxX - r.width }
if abs(r.maxY - bounds.maxY) < threshold { r.origin.y = bounds.maxY - r.height }
return r
}
private func updateCropDrag(_ value: DragGesture.Value, scale: CGFloat, origin: CGPoint) {
guard let current = model.cropRect else { return }
let bounds = CGRect(origin: .zero, size: imageSize)
// The crop may extend past the image (padding), capped at the
// image's largest side per edge so a runaway drag can't build a
// gigantic bitmap.
let cap = max(bounds.width, bounds.height)
let limit = bounds.insetBy(dx: -cap, dy: -cap)
if cropDrag == nil {
let cropView = CGRect(
x: origin.x + current.minX * scale, y: origin.y + current.minY * scale,
@@ -929,14 +992,15 @@ struct ScreenshotPane: View {
// 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 }
guard limit.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)
var newRect = Self.resizedCropRect(from: cropStartRect, drag: drag, delta: delta, bounds: limit)
newRect = Self.snapped(newRect, to: bounds, threshold: 10 / max(scale, 0.01))
guard newRect.width >= 1, newRect.height >= 1 else { return }
model.cropRect = newRect
}
@@ -988,12 +1052,26 @@ struct ScreenshotPane: View {
let strokeOffset = strokeDisplayOffset
return ZStack {
// Card behind the image
RoundedRectangle(cornerRadius: 10)
.fill(CapStyle.card)
.shadow(color: Color.black.opacity(0.08), radius: 8, y: 2)
.frame(width: drawSize.width, height: drawSize.height)
.position(x: cardRect.midX, y: cardRect.midY)
// Backing: normally a rounded card behind the image; in crop
// mode a flat canvas covering the image AND any padding the
// crop rect extends into.
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
)
let backing = cardRect.union(cropView)
Rectangle()
.fill(CapStyle.card)
.frame(width: backing.width, height: backing.height)
.position(x: backing.midX, y: backing.midY)
} else {
RoundedRectangle(cornerRadius: 10)
.fill(CapStyle.card)
.shadow(color: Color.black.opacity(0.08), radius: 8, y: 2)
.frame(width: drawSize.width, height: drawSize.height)
.position(x: cardRect.midX, y: cardRect.midY)
}
Image(nsImage: NSImage(cgImage: displayedImage, size: .zero))
.resizable()
@@ -1047,12 +1125,15 @@ struct ScreenshotPane: View {
.allowsHitTesting(false)
}
// Card hairline border
RoundedRectangle(cornerRadius: 10)
.strokeBorder(CapStyle.border, lineWidth: 1)
.frame(width: drawSize.width, height: drawSize.height)
.position(x: cardRect.midX, y: cardRect.midY)
.allowsHitTesting(false)
// Card hairline border (hidden in crop mode: the crop rect
// frames the canvas, and the border would cut across padding)
if model.tool != .crop {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(CapStyle.border, lineWidth: 1)
.frame(width: drawSize.width, height: drawSize.height)
.position(x: cardRect.midX, y: cardRect.midY)
.allowsHitTesting(false)
}
if model.tool == .crop, let crop = model.cropRect {
let cropView = CGRect(
@@ -1100,7 +1181,8 @@ struct ScreenshotPane: View {
context.stroke(dot, with: .color(.black.opacity(0.4)), lineWidth: 1)
}
}
.clipShape(cardClip)
// Not clipped to the card: the crop rect can extend past
// the image into padding, and its handles must show.
.allowsHitTesting(false)
}
+2 -2
View File
@@ -111,9 +111,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
<key>CFBundleIconFile</key>
<string>Icon</string>
<key>CFBundleShortVersionString</key>
<string>1.12.0</string>
<string>1.13.0</string>
<key>CFBundleVersion</key>
<string>120</string>
<string>130</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>