Compare commits

...
2 Commits
Author SHA1 Message Date
TZGyn de642ad692 Fix flipped drawing when copying/saving annotated images
The strokes were stored in top-left image coordinates but drawn into an
AppKit CGContext with a bottom-left origin, so exports came out mirrored
and offset. Flip Y in the context before stroking so annotations land
where they were drawn.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-24 09:08:37 +08:00
TZGyn 8b27263962 Make stroke thickness per-stroke; restore Clear button
Each drawn stroke now stores its own width and color, so changing the
thickness slider no longer retroactively resizes existing strokes. The
Clear button is back in the draw toolbar.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 14:00:00 +08:00
2 changed files with 38 additions and 15 deletions
+36 -13
View File
@@ -91,9 +91,9 @@ 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 /// Redo stack of removed strokes
@Published var redoStack: [[CGPoint]] = [] @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 /// Current drawing color
@@ -101,6 +101,12 @@ final class CaptureModel: ObservableObject {
/// Stroke thickness in points (image space) /// Stroke thickness in points (image space)
@Published var strokeWidth: CGFloat = 3.0 @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
case text case text
@@ -143,26 +149,33 @@ 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))
drawColor.setStroke() // Strokes are stored in TOP-LEFT image coordinates (from the SwiftUI
ctx.setLineWidth(strokeWidth) // 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 = [] redoStack = []
} }
@@ -387,6 +400,16 @@ struct ResultView: View {
.disabled(!model.canRedo) .disabled(!model.canRedo)
.keyboardShortcut("z", modifiers: [.command, .shift]) .keyboardShortcut("z", modifiers: [.command, .shift])
.help("Redo stroke (⌘⇧Z)") .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(.horizontal, 14)
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -490,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(Color(nsColor: model.drawColor)), lineWidth: model.strokeWidth)
} }
.allowsHitTesting(false) .allowsHitTesting(false)
+2 -2
View File
@@ -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.4.1</string> <string>1.4.3</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>41</string> <string>43</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>