Compare commits

...
4 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
TZGyn 1961d0dc9c Move draw controls into the draw toolbar
The color picker, undo/redo, and clear eraser now live in the dedicated
draw top bar instead of the main toolbar, keeping the primary actions
(Copy/Save/Get Text) uncluttered. Keyboard shortcuts (⌘Z/⌘⇧Z) remain.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:55:00 +08:00
TZGyn 41cb06dcb5 Add draw toolbar with thickness, undo/redo, and color picker
When drawing is enabled a dedicated top bar shows a thickness slider,
color picker, and undo/redo buttons (⌘Z / ⌘⇧Z). Strokes render and export
with the chosen width and color.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:39:06 +08:00
2 changed files with 120 additions and 40 deletions
+118 -38
View File
@@ -91,11 +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 /// Current drawing color
@Published var drawColor: NSColor = .systemRed @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
@@ -107,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 {
@@ -122,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))
drawColor.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 {
@@ -287,27 +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.isDrawing {
ColorPicker("", selection: Binding(
get: { Color(nsColor: model.drawColor) },
set: { model.drawColor = NSColor($0) }
), supportsOpacity: false)
.labelsHidden()
.frame(width: 28)
.help("Drawing color")
}
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: {
@@ -338,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)
@@ -433,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: 2.5)
} }
.allowsHitTesting(false) .allowsHitTesting(false)
@@ -455,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(Color(nsColor: model.drawColor)), lineWidth: 2.5) 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.3.3</string> <string>1.4.3</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>33</string> <string>43</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>