Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de642ad692 | ||
|
|
8b27263962 | ||
|
|
1961d0dc9c | ||
|
|
41cb06dcb5 | ||
|
|
52e36b5c5a |
@@ -91,9 +91,21 @@ final class CaptureModel: ObservableObject {
|
|||||||
/// Drag selection rect in IMAGE point space (nil = none)
|
/// Drag selection rect in IMAGE point space (nil = none)
|
||||||
@Published var selection: CGRect?
|
@Published var selection: CGRect?
|
||||||
/// Freehand drawing strokes in IMAGE point space
|
/// Freehand drawing strokes in IMAGE point space
|
||||||
@Published var strokes: [[CGPoint]] = []
|
@Published var strokes: [Stroke] = []
|
||||||
|
/// Redo stack of removed strokes
|
||||||
|
@Published var redoStack: [Stroke] = []
|
||||||
/// Whether the pen is active (drag draws instead of selecting)
|
/// Whether the pen is active (drag draws instead of selecting)
|
||||||
@Published var isDrawing = false
|
@Published var isDrawing = false
|
||||||
|
/// Current drawing color
|
||||||
|
@Published var drawColor: NSColor = .systemRed
|
||||||
|
/// Stroke thickness in points (image space)
|
||||||
|
@Published var strokeWidth: CGFloat = 3.0
|
||||||
|
|
||||||
|
struct Stroke {
|
||||||
|
let points: [CGPoint]
|
||||||
|
let width: CGFloat
|
||||||
|
let color: NSColor
|
||||||
|
}
|
||||||
|
|
||||||
enum CaptureMode {
|
enum CaptureMode {
|
||||||
case screenshot
|
case screenshot
|
||||||
@@ -105,6 +117,23 @@ final class CaptureModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var hasSelection: Bool { selection != nil && selection!.width >= 2 && selection!.height >= 2 }
|
var hasSelection: Bool { selection != nil && selection!.width >= 2 && selection!.height >= 2 }
|
||||||
|
var canUndo: Bool { !strokes.isEmpty }
|
||||||
|
var canRedo: Bool { !redoStack.isEmpty }
|
||||||
|
|
||||||
|
func undoStroke() {
|
||||||
|
guard let last = strokes.popLast() else { return }
|
||||||
|
redoStack.append(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
func redoStroke() {
|
||||||
|
guard let last = redoStack.popLast() else { return }
|
||||||
|
strokes.append(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAnnotations() {
|
||||||
|
strokes = []
|
||||||
|
redoStack = []
|
||||||
|
}
|
||||||
|
|
||||||
/// Image with drawn strokes flattened in, for copy/save/export.
|
/// Image with drawn strokes flattened in, for copy/save/export.
|
||||||
var annotatedImage: CGImage {
|
var annotatedImage: CGImage {
|
||||||
@@ -120,30 +149,34 @@ final class CaptureModel: ObservableObject {
|
|||||||
let ctx = NSGraphicsContext.current!.cgContext
|
let ctx = NSGraphicsContext.current!.cgContext
|
||||||
// CGImage draws with a flipped Y in NSGraphicsContext
|
// CGImage draws with a flipped Y in NSGraphicsContext
|
||||||
ctx.draw(image, in: NSRect(origin: .zero, size: size))
|
ctx.draw(image, in: NSRect(origin: .zero, size: size))
|
||||||
NSColor.systemRed.setStroke()
|
// Strokes are stored in TOP-LEFT image coordinates (from the SwiftUI
|
||||||
ctx.setLineWidth(3)
|
// canvas), but the CGContext uses a BOTTOM-LEFT origin. Flip Y so the
|
||||||
|
// strokes land where they were drawn.
|
||||||
|
ctx.saveGState()
|
||||||
|
ctx.translateBy(x: 0, y: size.height)
|
||||||
|
ctx.scaleBy(x: 1, y: -1)
|
||||||
ctx.setLineCap(.round)
|
ctx.setLineCap(.round)
|
||||||
ctx.setLineJoin(.round)
|
ctx.setLineJoin(.round)
|
||||||
for stroke in strokes {
|
for stroke in strokes {
|
||||||
guard stroke.count > 1 else { continue }
|
guard stroke.points.count > 1 else { continue }
|
||||||
|
stroke.color.setStroke()
|
||||||
|
ctx.setLineWidth(stroke.width)
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.move(to: stroke[0])
|
ctx.move(to: stroke.points[0])
|
||||||
for point in stroke.dropFirst() {
|
for point in stroke.points.dropFirst() {
|
||||||
ctx.addLine(to: point)
|
ctx.addLine(to: point)
|
||||||
}
|
}
|
||||||
ctx.strokePath()
|
ctx.strokePath()
|
||||||
}
|
}
|
||||||
|
ctx.restoreGState()
|
||||||
NSGraphicsContext.restoreGraphicsState()
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
return rep.cgImage ?? image
|
return rep.cgImage ?? image
|
||||||
}
|
}
|
||||||
|
|
||||||
func addStroke(_ points: [CGPoint]) {
|
func addStroke(_ points: [CGPoint]) {
|
||||||
guard points.count > 1 else { return }
|
guard points.count > 1 else { return }
|
||||||
strokes.append(points)
|
strokes.append(Stroke(points: points, width: strokeWidth, color: drawColor))
|
||||||
}
|
redoStack = []
|
||||||
|
|
||||||
func clearAnnotations() {
|
|
||||||
strokes = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var displayText: String {
|
var displayText: String {
|
||||||
@@ -285,17 +318,6 @@ struct ResultView: View {
|
|||||||
.foregroundStyle(model.isDrawing ? .primary : .secondary)
|
.foregroundStyle(model.isDrawing ? .primary : .secondary)
|
||||||
.help("Toggle drawing on the screenshot")
|
.help("Toggle drawing on the screenshot")
|
||||||
|
|
||||||
if !model.strokes.isEmpty {
|
|
||||||
Button {
|
|
||||||
model.clearAnnotations()
|
|
||||||
} label: {
|
|
||||||
Label("Clear", systemImage: "eraser")
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.help("Remove all drawn annotations")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
model.copyImage()
|
model.copyImage()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -326,6 +348,76 @@ struct ResultView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
// Draw toolbar (only when drawing)
|
||||||
|
if model.isDrawing {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Label("Draw", systemImage: "pencil.tip")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Divider().frame(height: 18)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "circle.fill").font(.system(size: 4))
|
||||||
|
Image(systemName: "circle.fill").font(.system(size: 7))
|
||||||
|
Image(systemName: "circle.fill").font(.system(size: 10))
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Slider(value: $model.strokeWidth, in: 1...12, step: 0.5)
|
||||||
|
.frame(width: 120)
|
||||||
|
.help("Stroke thickness")
|
||||||
|
|
||||||
|
Divider().frame(height: 18)
|
||||||
|
|
||||||
|
ColorPicker("", selection: Binding(
|
||||||
|
get: { Color(nsColor: model.drawColor) },
|
||||||
|
set: { model.drawColor = NSColor($0) }
|
||||||
|
), supportsOpacity: false)
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 28)
|
||||||
|
.help("Drawing color")
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
model.undoStroke()
|
||||||
|
} label: {
|
||||||
|
Label("Undo", systemImage: "arrow.uturn.backward")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
.disabled(!model.canUndo)
|
||||||
|
.keyboardShortcut("z", modifiers: .command)
|
||||||
|
.help("Undo last stroke (⌘Z)")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
model.redoStroke()
|
||||||
|
} label: {
|
||||||
|
Label("Redo", systemImage: "arrow.uturn.forward")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
.disabled(!model.canRedo)
|
||||||
|
.keyboardShortcut("z", modifiers: [.command, .shift])
|
||||||
|
.help("Redo stroke (⌘⇧Z)")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
model.clearAnnotations()
|
||||||
|
} label: {
|
||||||
|
Label("Clear", systemImage: "eraser")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
.disabled(model.strokes.isEmpty)
|
||||||
|
.help("Remove all drawn annotations")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.bar)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
if model.mode == .screenshot {
|
if model.mode == .screenshot {
|
||||||
ScreenshotPane(model: model)
|
ScreenshotPane(model: model)
|
||||||
@@ -421,15 +513,15 @@ struct ScreenshotPane: View {
|
|||||||
|
|
||||||
// Completed strokes
|
// Completed strokes
|
||||||
Canvas { context, _ in
|
Canvas { context, _ in
|
||||||
var path = Path()
|
|
||||||
for stroke in model.strokes {
|
for stroke in model.strokes {
|
||||||
guard stroke.count > 1 else { continue }
|
guard stroke.points.count > 1 else { continue }
|
||||||
path.move(to: CGPoint(x: origin.x + stroke[0].x * scale, y: origin.y + stroke[0].y * scale))
|
var path = Path()
|
||||||
for point in stroke.dropFirst() {
|
path.move(to: CGPoint(x: origin.x + stroke.points[0].x * scale, y: origin.y + stroke.points[0].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 * scale, y: origin.y + point.y * scale))
|
||||||
}
|
}
|
||||||
|
context.stroke(path, with: .color(Color(nsColor: stroke.color)), lineWidth: stroke.width)
|
||||||
}
|
}
|
||||||
context.stroke(path, with: .color(.red), lineWidth: 2.5)
|
|
||||||
}
|
}
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
|
|
||||||
@@ -443,7 +535,7 @@ struct ScreenshotPane: View {
|
|||||||
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 * scale, y: origin.y + point.y * scale))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
context.stroke(path, with: .color(.red), lineWidth: 2.5)
|
context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: model.strokeWidth)
|
||||||
}
|
}
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -108,9 +108,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.3.2</string>
|
<string>1.4.3</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>32</string>
|
<string>43</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
|
|||||||
Reference in New Issue
Block a user