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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user