From 062285abbfd7844cabf240425ed7188665f1323b Mon Sep 17 00:00:00 2001 From: Marco Wende Date: Wed, 1 Jul 2026 17:44:20 +0200 Subject: [PATCH] Add image editor with annotation tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Sources/AppCoordinator.swift | 61 +++- Sources/Editor/AnnotationCanvasView.swift | 310 ++++++++++++++++++++ Sources/Editor/AnnotationDocument.swift | 56 ++++ Sources/Editor/AnnotationItem.swift | 36 +++ Sources/Editor/AnnotationRenderer.swift | 139 +++++++++ Sources/Editor/EditorToolbarView.swift | 115 ++++++++ Sources/Editor/EditorWindowController.swift | 113 +++++++ Sources/MenuBarContentView.swift | 11 + Sources/SettingsWindowController.swift | 6 +- 9 files changed, 841 insertions(+), 6 deletions(-) create mode 100644 Sources/Editor/AnnotationCanvasView.swift create mode 100644 Sources/Editor/AnnotationDocument.swift create mode 100644 Sources/Editor/AnnotationItem.swift create mode 100644 Sources/Editor/AnnotationRenderer.swift create mode 100644 Sources/Editor/EditorToolbarView.swift create mode 100644 Sources/Editor/EditorWindowController.swift diff --git a/Sources/AppCoordinator.swift b/Sources/AppCoordinator.swift index bac4c3c..08f8d21 100644 --- a/Sources/AppCoordinator.swift +++ b/Sources/AppCoordinator.swift @@ -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" diff --git a/Sources/Editor/AnnotationCanvasView.swift b/Sources/Editor/AnnotationCanvasView.swift new file mode 100644 index 0000000..e582005 --- /dev/null +++ b/Sources/Editor/AnnotationCanvasView.swift @@ -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) + } +} diff --git a/Sources/Editor/AnnotationDocument.swift b/Sources/Editor/AnnotationDocument.swift new file mode 100644 index 0000000..92366f0 --- /dev/null +++ b/Sources/Editor/AnnotationDocument.swift @@ -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 + } +} diff --git a/Sources/Editor/AnnotationItem.swift b/Sources/Editor/AnnotationItem.swift new file mode 100644 index 0000000..7d013b5 --- /dev/null +++ b/Sources/Editor/AnnotationItem.swift @@ -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 + } +} diff --git a/Sources/Editor/AnnotationRenderer.swift b/Sources/Editor/AnnotationRenderer.swift new file mode 100644 index 0000000..0839902 --- /dev/null +++ b/Sources/Editor/AnnotationRenderer.swift @@ -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) + } +} diff --git a/Sources/Editor/EditorToolbarView.swift b/Sources/Editor/EditorToolbarView.swift new file mode 100644 index 0000000..8248c69 --- /dev/null +++ b/Sources/Editor/EditorToolbarView.swift @@ -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) + } + } +} diff --git a/Sources/Editor/EditorWindowController.swift b/Sources/Editor/EditorWindowController.swift new file mode 100644 index 0000000..ec99ed1 --- /dev/null +++ b/Sources/Editor/EditorWindowController.swift @@ -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) + } + } +} diff --git a/Sources/MenuBarContentView.swift b/Sources/MenuBarContentView.swift index 55223f6..64d73ae 100644 --- a/Sources/MenuBarContentView.swift +++ b/Sources/MenuBarContentView.swift @@ -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) } diff --git a/Sources/SettingsWindowController.swift b/Sources/SettingsWindowController.swift index c27f2ba..085e9f5 100644 --- a/Sources/SettingsWindowController.swift +++ b/Sources/SettingsWindowController.swift @@ -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) + } } }