Add image editor with annotation tools

Integrates an editor accessible via "Letzte Aufnahme bearbeiten…" or
"Bild bearbeiten…" in the menu bar. Supports arrow, rectangle, ellipse,
and text annotations with color picker, line width, undo/redo, and
export to file or clipboard.

New files in Sources/Editor/:
- AnnotationItem/Document: data model and state management
- AnnotationCanvasView: NSView with CG rendering and mouse interaction
- AnnotationRenderer: shared rendering for canvas and image export
- EditorToolbarView: SwiftUI toolbar with tool selection and actions
- EditorWindowController: window lifecycle following SettingsWindowController pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 17:44:20 +02:00
co-authored by Claude Opus 4.6
parent ecbae7cd21
commit 062285abbf
9 changed files with 841 additions and 6 deletions
+57 -4
View File
@@ -36,6 +36,9 @@ final class AppCoordinator: ObservableObject {
didSet { persistAndRegister(mode: .region, combo: hotKeyRegion) }
}
@Published var lastCapturedURL: URL?
private var editorController: EditorWindowController?
init() {
let defaults = UserDefaults.standard
@@ -157,10 +160,9 @@ final class AppCoordinator: ObservableObject {
}
private func handleCaptured(at url: URL, saves: Bool, copies: Bool) {
guard FileManager.default.fileExists(atPath: url.path) else {
// User cancelled the interactive capture (Esc).
return
}
guard FileManager.default.fileExists(atPath: url.path) else { return }
lastCapturedURL = saves ? url : nil
if copies, let image = NSImage(contentsOf: url) {
let pasteboard = NSPasteboard.general
@@ -173,6 +175,57 @@ final class AppCoordinator: ObservableObject {
}
}
// MARK: - Editor
func openEditorForLastCapture() {
guard let url = lastCapturedURL,
FileManager.default.fileExists(atPath: url.path),
let image = NSImage(contentsOf: url) else { return }
openEditor(image: image, sourceURL: url)
}
func openEditorForFile() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.png, .jpeg, .tiff, .bmp]
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
guard panel.runModal() == .OK, let url = panel.url,
let image = NSImage(contentsOf: url) else { return }
openEditor(image: image, sourceURL: url)
}
private func openEditor(image: NSImage, sourceURL: URL) {
let controller = EditorWindowController(image: image, sourceURL: sourceURL) { [weak self] annotatedImage in
guard let self, let annotatedImage else { return }
self.saveAnnotatedImage(annotatedImage, replacingFileAt: sourceURL)
}
editorController = controller
controller.showEditor()
}
private func saveAnnotatedImage(_ image: NSImage, replacingFileAt url: URL) {
guard let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData) else { return }
let fileType: NSBitmapImageRep.FileType = imageFormat == .jpg ? .jpeg : .png
let properties: [NSBitmapImageRep.PropertyKey: Any] = fileType == .jpeg
? [.compressionFactor: 0.9] : [:]
guard let data = bitmap.representation(using: fileType, properties: properties) else { return }
do {
try data.write(to: url, options: .atomic)
} catch {
NSLog("SnippingTool: failed to save annotated image: \(error)")
}
if outputAction.copiesToClipboard {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.writeObjects([image])
}
}
private static func filename(ext: String) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd 'um' HH.mm.ss"
+310
View File
@@ -0,0 +1,310 @@
import AppKit
import Foundation
final class AnnotationCanvasView: NSView {
var baseImage: NSImage? { didSet { needsDisplay = true } }
var document: AnnotationDocument? { didSet { needsDisplay = true } }
var onDocumentChanged: (() -> Void)?
private enum Interaction {
case idle
case creating(AnnotationKind, startPoint: CGPoint)
case dragging(UUID, lastPoint: CGPoint)
}
private var interaction: Interaction = .idle
private var inProgressItem: AnnotationItem?
private var textField: NSTextField?
override var isFlipped: Bool { true }
override var acceptsFirstResponder: Bool { true }
// MARK: - Coordinate transform
private var imageRect: CGRect {
guard let image = baseImage else { return bounds }
let imageSize = image.size
let scale = min(bounds.width / imageSize.width, bounds.height / imageSize.height)
let scaledSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
return CGRect(
x: (bounds.width - scaledSize.width) / 2,
y: (bounds.height - scaledSize.height) / 2,
width: scaledSize.width,
height: scaledSize.height
)
}
private var viewToImageScale: CGFloat {
guard let image = baseImage else { return 1 }
return image.size.width / imageRect.width
}
private func viewToImage(_ point: CGPoint) -> CGPoint {
let rect = imageRect
let scale = viewToImageScale
return CGPoint(
x: (point.x - rect.origin.x) * scale,
y: (point.y - rect.origin.y) * scale
)
}
private func imageToView(_ point: CGPoint) -> CGPoint {
let rect = imageRect
let scale = viewToImageScale
return CGPoint(
x: point.x / scale + rect.origin.x,
y: point.y / scale + rect.origin.y
)
}
private func imageRectToView(_ r: CGRect) -> CGRect {
let origin = imageToView(r.origin)
let scale = viewToImageScale
return CGRect(x: origin.x, y: origin.y,
width: r.width / scale, height: r.height / scale)
}
// MARK: - Drawing
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
let bgColor: CGColor = NSColor.windowBackgroundColor.cgColor
context.setFillColor(bgColor)
context.fill(bounds)
guard let image = baseImage else { return }
let imgRect = imageRect
NSGraphicsContext.current?.imageInterpolation = .high
image.draw(in: imgRect, from: .zero, operation: .sourceOver, fraction: 1)
context.saveGState()
context.translateBy(x: imgRect.origin.x, y: imgRect.origin.y)
let scale = 1.0 / viewToImageScale
context.scaleBy(x: scale, y: scale)
guard let doc = document else {
context.restoreGState()
return
}
for item in doc.items {
AnnotationRenderer.draw(item, in: context)
if item.id == doc.selectedItemID {
drawSelectionIndicator(for: item, in: context)
}
}
if let inProgress = inProgressItem {
AnnotationRenderer.draw(inProgress, in: context)
}
context.restoreGState()
}
private func drawSelectionIndicator(for item: AnnotationItem, in context: CGContext) {
let handleRect: CGRect
if item.kind == .arrow {
let start = item.rect.origin
let end = CGPoint(x: item.rect.maxX, y: item.rect.maxY)
handleRect = CGRect(x: min(start.x, end.x), y: min(start.y, end.y),
width: abs(end.x - start.x), height: abs(end.y - start.y))
} else if item.kind == .text {
guard let text = item.text, !text.isEmpty else { return }
let font = NSFont.systemFont(ofSize: item.fontSize, weight: .semibold)
let size = (text as NSString).size(withAttributes: [.font: font])
handleRect = CGRect(origin: item.rect.origin, size: size)
} else {
handleRect = item.rect
}
context.saveGState()
let dashPattern: [CGFloat] = [6, 4]
context.setLineDash(phase: 0, lengths: dashPattern)
context.setStrokeColor(NSColor.controlAccentColor.cgColor)
context.setLineWidth(1.5)
context.stroke(handleRect.insetBy(dx: -4, dy: -4))
context.restoreGState()
}
// MARK: - Mouse events
override func mouseDown(with event: NSEvent) {
commitTextField()
window?.makeFirstResponder(self)
let viewPoint = convert(event.locationInWindow, from: nil)
let imgPoint = viewToImage(viewPoint)
guard let doc = document else { return }
// Hit-test existing items (reverse order = topmost first)
for item in doc.items.reversed() {
if AnnotationRenderer.hitTest(point: imgPoint, item: item) {
doc.selectedItemID = item.id
interaction = .dragging(item.id, lastPoint: imgPoint)
needsDisplay = true
return
}
}
doc.selectedItemID = nil
interaction = .creating(doc.currentTool, startPoint: imgPoint)
needsDisplay = true
}
override func mouseDragged(with event: NSEvent) {
let viewPoint = convert(event.locationInWindow, from: nil)
let imgPoint = viewToImage(viewPoint)
guard let doc = document else { return }
switch interaction {
case .creating(let kind, let start):
if kind == .text { return }
let rect = CGRect(x: start.x, y: start.y,
width: imgPoint.x - start.x,
height: imgPoint.y - start.y)
inProgressItem = AnnotationItem(
kind: kind,
rect: rect,
color: doc.currentColor,
lineWidth: doc.currentLineWidth
)
needsDisplay = true
case .dragging(let id, let lastPoint):
let delta = CGSize(width: imgPoint.x - lastPoint.x,
height: imgPoint.y - lastPoint.y)
doc.moveSelected(by: delta)
interaction = .dragging(id, lastPoint: imgPoint)
needsDisplay = true
case .idle:
break
}
}
override func mouseUp(with event: NSEvent) {
let viewPoint = convert(event.locationInWindow, from: nil)
let imgPoint = viewToImage(viewPoint)
guard let doc = document else { return }
switch interaction {
case .creating(let kind, let start):
if kind == .text {
showTextField(at: viewPoint, imagePoint: imgPoint)
} else if let item = inProgressItem {
let dx = abs(item.rect.width)
let dy = abs(item.rect.height)
if dx > 4 || dy > 4 {
let normalized = normalizedRect(from: start, to: imgPoint, kind: kind)
var finalItem = item
finalItem.rect = normalized
doc.addItem(finalItem)
onDocumentChanged?()
}
}
inProgressItem = nil
case .dragging(_, _):
doc.pushUndo()
onDocumentChanged?()
case .idle:
break
}
interaction = .idle
needsDisplay = true
}
private func normalizedRect(from start: CGPoint, to end: CGPoint, kind: AnnotationKind) -> CGRect {
if kind == .arrow {
return CGRect(x: start.x, y: start.y,
width: end.x - start.x, height: end.y - start.y)
}
return CGRect(
x: min(start.x, end.x), y: min(start.y, end.y),
width: abs(end.x - start.x), height: abs(end.y - start.y)
)
}
// MARK: - Text editing
private func showTextField(at viewPoint: CGPoint, imagePoint: CGPoint) {
let field = NSTextField(frame: NSRect(x: viewPoint.x, y: viewPoint.y, width: 200, height: 30))
field.font = .systemFont(ofSize: 16, weight: .semibold)
field.textColor = document?.currentColor ?? .systemRed
field.backgroundColor = .white.withAlphaComponent(0.9)
field.isBordered = true
field.focusRingType = .exterior
field.placeholderString = "Text eingeben…"
field.target = self
field.action = #selector(textFieldCommitted(_:))
field.tag = Int(imagePoint.x) // store image coordinates in tag
addSubview(field)
window?.makeFirstResponder(field)
// Store image Y in field's identifier
field.identifier = NSUserInterfaceItemIdentifier("\(imagePoint.x),\(imagePoint.y)")
textField = field
}
@objc private func textFieldCommitted(_ sender: NSTextField) {
commitTextField()
}
private func commitTextField() {
guard let field = textField, let doc = document else { return }
let text = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty, let identifier = field.identifier?.rawValue {
let parts = identifier.split(separator: ",")
if parts.count == 2,
let x = Double(parts[0]),
let y = Double(parts[1]) {
let item = AnnotationItem(
kind: .text,
rect: CGRect(x: x, y: y, width: 0, height: 0),
color: doc.currentColor,
lineWidth: doc.currentLineWidth,
text: text,
fontSize: 24 * viewToImageScale
)
doc.addItem(item)
onDocumentChanged?()
needsDisplay = true
}
}
field.removeFromSuperview()
textField = nil
}
// MARK: - Keyboard
override func keyDown(with event: NSEvent) {
if event.modifierFlags.contains(.command) {
if event.charactersIgnoringModifiers == "z" {
if event.modifierFlags.contains(.shift) {
document?.redo()
} else {
document?.undo()
}
needsDisplay = true
onDocumentChanged?()
return
}
}
if event.keyCode == 51 || event.keyCode == 117 { // Backspace or Delete
document?.deleteSelected()
needsDisplay = true
onDocumentChanged?()
return
}
super.keyDown(with: event)
}
}
+56
View File
@@ -0,0 +1,56 @@
import AppKit
import Foundation
@Observable
final class AnnotationDocument {
var items: [AnnotationItem] = []
var selectedItemID: UUID?
var currentTool: AnnotationKind = .arrow
var currentColor: NSColor = .systemRed
var currentLineWidth: CGFloat = 3
private var undoStack: [[AnnotationItem]] = []
private var redoStack: [[AnnotationItem]] = []
var canUndo: Bool { !undoStack.isEmpty }
var canRedo: Bool { !redoStack.isEmpty }
func pushUndo() {
undoStack.append(items)
redoStack.removeAll()
}
func undo() {
guard let previous = undoStack.popLast() else { return }
redoStack.append(items)
items = previous
selectedItemID = nil
}
func redo() {
guard let next = redoStack.popLast() else { return }
undoStack.append(items)
items = next
selectedItemID = nil
}
func addItem(_ item: AnnotationItem) {
pushUndo()
items.append(item)
selectedItemID = item.id
}
func deleteSelected() {
guard let id = selectedItemID else { return }
pushUndo()
items.removeAll { $0.id == id }
selectedItemID = nil
}
func moveSelected(by delta: CGSize) {
guard let id = selectedItemID,
let index = items.firstIndex(where: { $0.id == id }) else { return }
items[index].rect.origin.x += delta.width
items[index].rect.origin.y += delta.height
}
}
+36
View File
@@ -0,0 +1,36 @@
import AppKit
import Foundation
enum AnnotationKind: String, CaseIterable, Codable {
case arrow
case rectangle
case ellipse
case text
}
struct AnnotationItem: Identifiable {
let id: UUID
var kind: AnnotationKind
var rect: CGRect
var color: NSColor
var lineWidth: CGFloat
var text: String?
var fontSize: CGFloat
init(
kind: AnnotationKind,
rect: CGRect,
color: NSColor = .systemRed,
lineWidth: CGFloat = 3,
text: String? = nil,
fontSize: CGFloat = 24
) {
self.id = UUID()
self.kind = kind
self.rect = rect
self.color = color
self.lineWidth = lineWidth
self.text = text
self.fontSize = fontSize
}
}
+139
View File
@@ -0,0 +1,139 @@
import AppKit
import Foundation
enum AnnotationRenderer {
static func draw(_ item: AnnotationItem, in context: CGContext) {
context.saveGState()
context.setStrokeColor(item.color.cgColor)
context.setFillColor(item.color.cgColor)
context.setLineWidth(item.lineWidth)
context.setLineCap(.round)
context.setLineJoin(.round)
switch item.kind {
case .arrow:
drawArrow(item, in: context)
case .rectangle:
drawRectangle(item, in: context)
case .ellipse:
drawEllipse(item, in: context)
case .text:
drawText(item)
}
context.restoreGState()
}
static func render(annotations: [AnnotationItem], onto image: NSImage) -> NSImage {
let size = image.size
let result = NSImage(size: size)
result.lockFocus()
image.draw(in: NSRect(origin: .zero, size: size))
if let context = NSGraphicsContext.current?.cgContext {
for item in annotations {
draw(item, in: context)
}
}
result.unlockFocus()
return result
}
// MARK: - Shape drawing
private static func drawArrow(_ item: AnnotationItem, in context: CGContext) {
let start = item.rect.origin
let end = CGPoint(x: item.rect.maxX, y: item.rect.maxY)
context.move(to: start)
context.addLine(to: end)
context.strokePath()
let angle = atan2(end.y - start.y, end.x - start.x)
let headLength = max(item.lineWidth * 4, 14)
let headAngle: CGFloat = .pi / 6
let left = CGPoint(
x: end.x - headLength * cos(angle - headAngle),
y: end.y - headLength * sin(angle - headAngle)
)
let right = CGPoint(
x: end.x - headLength * cos(angle + headAngle),
y: end.y - headLength * sin(angle + headAngle)
)
context.move(to: end)
context.addLine(to: left)
context.addLine(to: right)
context.closePath()
context.fillPath()
}
private static func drawRectangle(_ item: AnnotationItem, in context: CGContext) {
context.stroke(item.rect, width: item.lineWidth)
}
private static func drawEllipse(_ item: AnnotationItem, in context: CGContext) {
context.strokeEllipse(in: item.rect)
}
private static func drawText(_ item: AnnotationItem) {
guard let text = item.text, !text.isEmpty else { return }
let font = NSFont.systemFont(ofSize: item.fontSize, weight: .semibold)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: item.color,
]
let string = NSAttributedString(string: text, attributes: attributes)
string.draw(at: item.rect.origin)
}
// MARK: - Hit testing
static func hitTest(point: CGPoint, item: AnnotationItem, tolerance: CGFloat = 6) -> Bool {
switch item.kind {
case .arrow:
return distanceToLine(point: point,
from: item.rect.origin,
to: CGPoint(x: item.rect.maxX, y: item.rect.maxY)) < tolerance + item.lineWidth
case .rectangle:
let outer = item.rect.insetBy(dx: -(tolerance + item.lineWidth),
dy: -(tolerance + item.lineWidth))
let inner = item.rect.insetBy(dx: tolerance + item.lineWidth,
dy: tolerance + item.lineWidth)
return outer.contains(point) && !inner.contains(point)
case .ellipse:
let center = CGPoint(x: item.rect.midX, y: item.rect.midY)
let rx = item.rect.width / 2
let ry = item.rect.height / 2
guard rx > 0, ry > 0 else { return false }
let dx = (point.x - center.x) / rx
let dy = (point.y - center.y) / ry
let dist = sqrt(dx * dx + dy * dy)
return abs(dist - 1.0) < (tolerance + item.lineWidth) / min(rx, ry)
case .text:
guard let text = item.text, !text.isEmpty else { return false }
let font = NSFont.systemFont(ofSize: item.fontSize, weight: .semibold)
let size = (text as NSString).size(withAttributes: [.font: font])
let textRect = CGRect(origin: item.rect.origin, size: size)
return textRect.insetBy(dx: -tolerance, dy: -tolerance).contains(point)
}
}
private static func distanceToLine(point: CGPoint, from a: CGPoint, to b: CGPoint) -> CGFloat {
let dx = b.x - a.x
let dy = b.y - a.y
let lengthSq = dx * dx + dy * dy
guard lengthSq > 0 else { return hypot(point.x - a.x, point.y - a.y) }
var t = ((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSq
t = max(0, min(1, t))
let projX = a.x + t * dx
let projY = a.y + t * dy
return hypot(point.x - projX, point.y - projY)
}
}
+115
View File
@@ -0,0 +1,115 @@
import SwiftUI
struct EditorToolbarView: View {
@Bindable var document: AnnotationDocument
var onSave: () -> Void
var onCopyToClipboard: () -> Void
var onCancel: () -> Void
var body: some View {
HStack(spacing: 12) {
toolButtons
Divider().frame(height: 24)
colorPicker
lineWidthPicker
Divider().frame(height: 24)
undoRedoButtons
Spacer()
actionButtons
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.bar)
}
private var toolButtons: some View {
HStack(spacing: 4) {
toolButton(.arrow, icon: "arrow.up.right", label: "Pfeil")
toolButton(.rectangle, icon: "rectangle", label: "Rechteck")
toolButton(.ellipse, icon: "circle", label: "Ellipse")
toolButton(.text, icon: "textformat", label: "Text")
}
}
private func toolButton(_ kind: AnnotationKind, icon: String, label: String) -> some View {
Button {
document.currentTool = kind
} label: {
Image(systemName: icon)
.frame(width: 28, height: 28)
}
.buttonStyle(.bordered)
.tint(document.currentTool == kind ? .accentColor : nil)
.help(label)
}
private var colorPicker: some View {
HStack(spacing: 4) {
ForEach(presetColors, id: \.self) { color in
Circle()
.fill(Color(nsColor: color))
.frame(width: 20, height: 20)
.overlay {
if document.currentColor == color {
Circle().strokeBorder(.white, lineWidth: 2)
}
}
.shadow(radius: document.currentColor == color ? 2 : 0)
.onTapGesture { document.currentColor = color }
}
ColorPicker("", selection: Binding(
get: { Color(nsColor: document.currentColor) },
set: { document.currentColor = NSColor($0) }
))
.labelsHidden()
.frame(width: 28)
}
}
private var presetColors: [NSColor] {
[.systemRed, .systemBlue, .systemGreen, .systemYellow, .black, .white]
}
private var lineWidthPicker: some View {
Picker("", selection: $document.currentLineWidth) {
Text("Dünn").tag(CGFloat(2))
Text("Mittel").tag(CGFloat(3))
Text("Dick").tag(CGFloat(5))
}
.pickerStyle(.segmented)
.frame(width: 160)
.labelsHidden()
}
private var undoRedoButtons: some View {
HStack(spacing: 4) {
Button {
document.undo()
} label: {
Image(systemName: "arrow.uturn.backward")
}
.disabled(!document.canUndo)
.help("Widerrufen (⌘Z)")
Button {
document.redo()
} label: {
Image(systemName: "arrow.uturn.forward")
}
.disabled(!document.canRedo)
.help("Wiederholen (⌘⇧Z)")
}
}
private var actionButtons: some View {
HStack(spacing: 8) {
Button("Abbrechen", role: .cancel) { onCancel() }
.keyboardShortcut(.escape)
Button("In Zwischenablage") { onCopyToClipboard() }
Button("Speichern") { onSave() }
.keyboardShortcut("s")
.buttonStyle(.borderedProminent)
}
}
}
+113
View File
@@ -0,0 +1,113 @@
import AppKit
import SwiftUI
final class EditorWindowController: NSWindowController, NSWindowDelegate {
private let annotationDoc = AnnotationDocument()
private let baseImage: NSImage
private let sourceURL: URL?
private let completion: (NSImage?) -> Void
private let canvasView = AnnotationCanvasView()
init(image: NSImage, sourceURL: URL?, completion: @escaping (NSImage?) -> Void) {
self.baseImage = image
self.sourceURL = sourceURL
self.completion = completion
let screenFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
let maxWidth = screenFrame.width * 0.8
let maxHeight = screenFrame.height * 0.8
let scale = min(maxWidth / image.size.width, maxHeight / image.size.height, 1.0)
let windowWidth = max(image.size.width * scale, 600)
let windowHeight = max(image.size.height * scale, 400) + 52 // toolbar height
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.title = "Bildeditor"
window.isReleasedWhenClosed = false
window.center()
window.minSize = NSSize(width: 500, height: 400)
super.init(window: window)
window.delegate = self
canvasView.baseImage = image
canvasView.document = annotationDoc
canvasView.onDocumentChanged = { [weak self] in
self?.canvasView.needsDisplay = true
}
canvasView.autoresizingMask = [.width, .height]
let toolbarHosting = NSHostingView(rootView: EditorToolbarView(
document: annotationDoc,
onSave: { [weak self] in self?.save() },
onCopyToClipboard: { [weak self] in self?.copyToClipboard() },
onCancel: { [weak self] in self?.cancel() }
))
toolbarHosting.translatesAutoresizingMaskIntoConstraints = true
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
container.autoresizingMask = [.width, .height]
toolbarHosting.frame = NSRect(x: 0, y: container.bounds.height - 52,
width: container.bounds.width, height: 52)
toolbarHosting.autoresizingMask = [.width, .minYMargin]
canvasView.frame = NSRect(x: 0, y: 0,
width: container.bounds.width,
height: container.bounds.height - 52)
canvasView.autoresizingMask = [.width, .height]
container.addSubview(toolbarHosting)
container.addSubview(canvasView)
window.contentView = container
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func showEditor() {
NSApplication.shared.setActivationPolicy(.regular)
NSApplication.shared.activate(ignoringOtherApps: true)
window?.makeKeyAndOrderFront(nil)
window?.makeFirstResponder(canvasView)
}
// MARK: - Actions
private func renderFinal() -> NSImage {
AnnotationRenderer.render(annotations: annotationDoc.items, onto: baseImage)
}
private func save() {
let image = renderFinal()
completion(image)
close()
}
private func copyToClipboard() {
let image = renderFinal()
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.writeObjects([image])
close()
}
private func cancel() {
completion(nil)
close()
}
// MARK: - NSWindowDelegate
func windowWillClose(_ notification: Notification) {
let visibleWindows = NSApplication.shared.windows.filter { $0.isVisible && $0 != window }
if visibleWindows.isEmpty {
NSApplication.shared.setActivationPolicy(.accessory)
}
}
}
+11
View File
@@ -11,6 +11,17 @@ struct MenuBarContentView: View {
Divider()
Button("Letzte Aufnahme bearbeiten…") {
coordinator.openEditorForLastCapture()
}
.disabled(coordinator.lastCapturedURL == nil)
Button("Bild bearbeiten…") {
coordinator.openEditorForFile()
}
Divider()
Button("Einstellungen…") {
SettingsWindowController.shared.show(coordinator: coordinator)
}
+4 -2
View File
@@ -32,7 +32,9 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
}
func windowWillClose(_ notification: Notification) {
// Menu bar-only apps should not keep a Dock icon after the window closes.
NSApplication.shared.setActivationPolicy(.accessory)
let otherVisible = NSApplication.shared.windows.filter { $0.isVisible && $0 != window }
if otherVisible.isEmpty {
NSApplication.shared.setActivationPolicy(.accessory)
}
}
}