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:
@@ -182,6 +182,12 @@ final class CaptureModel: ObservableObject {
|
|||||||
if tool == .crop { tool = .select }
|
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.
|
/// Apply the pending crop rect and leave crop mode.
|
||||||
func applyCrop() {
|
func applyCrop() {
|
||||||
guard let rect = cropRect else { return }
|
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
|
/// Crop to `rect` in the DISPLAYED image's pixel space (the original
|
||||||
/// image once a crop exists). Strokes are translated into the new
|
/// image once a crop exists). The rect may extend past the image: the
|
||||||
/// coordinate space; strokes fully outside the crop are dropped. The
|
/// outside becomes transparent padding. Strokes are translated into the
|
||||||
/// previous state is pushed onto the crop undo stack.
|
/// 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) {
|
func crop(to rectInDisplayedSpace: CGRect) {
|
||||||
let source = originalImage ?? image
|
let source = originalImage ?? image
|
||||||
let bounds = CGRect(origin: .zero, size: CGSize(width: source.width, height: source.height))
|
let sourceBounds = CGRect(origin: .zero, size: CGSize(width: source.width, height: source.height))
|
||||||
let rect = rectInDisplayedSpace.integral.intersection(bounds)
|
let rect = rectInDisplayedSpace.integral
|
||||||
// No-op when the crop matches what's already visible.
|
// No-op when the crop matches what's already visible.
|
||||||
let currentVisible = CGRect(origin: cropOffset, size: CGSize(width: image.width, height: image.height))
|
let currentVisible = CGRect(origin: cropOffset, size: CGSize(width: image.width, height: image.height))
|
||||||
guard rect.width >= 2, rect.height >= 2, rect != currentVisible,
|
guard rect.width >= 2, rect.height >= 2, rect != currentVisible else { return }
|
||||||
let cropped = source.cropping(to: rect) 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))
|
cropHistory.append((image: image, strokes: strokes, redoStack: redoStack, offset: cropOffset))
|
||||||
image = cropped
|
image = cropped
|
||||||
if originalImage == nil { originalImage = source }
|
if originalImage == nil { originalImage = source }
|
||||||
@@ -330,6 +345,33 @@ final class CaptureModel: ObservableObject {
|
|||||||
statusMessage = "Cropped to \(Int(rect.width))×\(Int(rect.height))."
|
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) {
|
func runOCR(over selectionOnly: CGRect? = nil) {
|
||||||
guard !isOCRRunning else { return }
|
guard !isOCRRunning else { return }
|
||||||
isOCRRunning = true
|
isOCRRunning = true
|
||||||
@@ -711,7 +753,7 @@ struct ResultView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} 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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -719,6 +761,11 @@ struct ResultView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if model.tool == .crop {
|
if model.tool == .crop {
|
||||||
|
CapCapsuleButton(
|
||||||
|
title: "Reset", icon: "arrow.counterclockwise",
|
||||||
|
help: "Reset the crop to the current image"
|
||||||
|
) { model.resetCrop() }
|
||||||
|
|
||||||
CapCapsuleButton(
|
CapCapsuleButton(
|
||||||
title: "Cancel", icon: "xmark",
|
title: "Cancel", icon: "xmark",
|
||||||
help: "Cancel cropping (Esc)",
|
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) {
|
private func updateCropDrag(_ value: DragGesture.Value, scale: CGFloat, origin: CGPoint) {
|
||||||
guard let current = model.cropRect else { return }
|
guard let current = model.cropRect else { return }
|
||||||
let bounds = CGRect(origin: .zero, size: imageSize)
|
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 {
|
if cropDrag == nil {
|
||||||
let cropView = CGRect(
|
let cropView = CGRect(
|
||||||
x: origin.x + current.minX * scale, y: origin.y + current.minY * scale,
|
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
|
// Started outside the crop rect: draw a fresh one anchored
|
||||||
// where the drag began (handled by the .se anchor math).
|
// where the drag began (handled by the .se anchor math).
|
||||||
let anchor = toImagePoint(value.startLocation, scale: scale, origin: origin)
|
let anchor = toImagePoint(value.startLocation, scale: scale, origin: origin)
|
||||||
guard bounds.contains(anchor) else { return }
|
guard limit.contains(anchor) else { return }
|
||||||
cropDrag = .se
|
cropDrag = .se
|
||||||
cropStartRect = CGRect(origin: anchor, size: .zero)
|
cropStartRect = CGRect(origin: anchor, size: .zero)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard let drag = cropDrag else { return }
|
guard let drag = cropDrag else { return }
|
||||||
let delta = CGSize(width: value.translation.width / scale, height: value.translation.height / scale)
|
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 }
|
guard newRect.width >= 1, newRect.height >= 1 else { return }
|
||||||
model.cropRect = newRect
|
model.cropRect = newRect
|
||||||
}
|
}
|
||||||
@@ -988,12 +1052,26 @@ struct ScreenshotPane: View {
|
|||||||
let strokeOffset = strokeDisplayOffset
|
let strokeOffset = strokeDisplayOffset
|
||||||
|
|
||||||
return ZStack {
|
return ZStack {
|
||||||
// Card behind the image
|
// Backing: normally a rounded card behind the image; in crop
|
||||||
RoundedRectangle(cornerRadius: 10)
|
// mode a flat canvas covering the image AND any padding the
|
||||||
.fill(CapStyle.card)
|
// crop rect extends into.
|
||||||
.shadow(color: Color.black.opacity(0.08), radius: 8, y: 2)
|
if model.tool == .crop, let crop = model.cropRect {
|
||||||
.frame(width: drawSize.width, height: drawSize.height)
|
let cropView = CGRect(
|
||||||
.position(x: cardRect.midX, y: cardRect.midY)
|
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))
|
Image(nsImage: NSImage(cgImage: displayedImage, size: .zero))
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -1047,12 +1125,15 @@ struct ScreenshotPane: View {
|
|||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Card hairline border
|
// Card hairline border (hidden in crop mode: the crop rect
|
||||||
RoundedRectangle(cornerRadius: 10)
|
// frames the canvas, and the border would cut across padding)
|
||||||
.strokeBorder(CapStyle.border, lineWidth: 1)
|
if model.tool != .crop {
|
||||||
.frame(width: drawSize.width, height: drawSize.height)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.position(x: cardRect.midX, y: cardRect.midY)
|
.strokeBorder(CapStyle.border, lineWidth: 1)
|
||||||
.allowsHitTesting(false)
|
.frame(width: drawSize.width, height: drawSize.height)
|
||||||
|
.position(x: cardRect.midX, y: cardRect.midY)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
|
||||||
if model.tool == .crop, let crop = model.cropRect {
|
if model.tool == .crop, let crop = model.cropRect {
|
||||||
let cropView = CGRect(
|
let cropView = CGRect(
|
||||||
@@ -1100,7 +1181,8 @@ struct ScreenshotPane: View {
|
|||||||
context.stroke(dot, with: .color(.black.opacity(0.4)), lineWidth: 1)
|
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)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -111,9 +111,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
|
|||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>Icon</string>
|
<string>Icon</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.12.0</string>
|
<string>1.13.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>120</string>
|
<string>130</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
|
|||||||
Reference in New Issue
Block a user