import SwiftUI #if canImport(UIKit) import UIKit #endif /// Mask-Editor: Bild anzeigen, mit Drag-Gesten Rechtecke zeichnen, jede /// Region mit Label versehen. Coordinaten 0..1 relativ zur Bild-Größe. /// /// Output binding ist `regions`. Caller serialisiert via `MaskRegions.encode()`. struct MaskEditorView: View { let image: PlatformImage @Binding var regions: [MaskRegion] @State private var dragStart: CGPoint? @State private var dragEnd: CGPoint? @State private var nextIdCounter: Int = 0 var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Tippe und ziehe auf das Bild, um eine Maske zu erstellen.") .font(.caption) .foregroundStyle(CardsTheme.mutedForeground) imageCanvas .aspectRatio(image.size.width / max(image.size.height, 1), contentMode: .fit) .frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: 8)) if regions.isEmpty { Text("Noch keine Maske") .font(.caption) .foregroundStyle(CardsTheme.mutedForeground) } else { ForEach(regions) { region in maskRow(region: region) } } } } private var imageCanvas: some View { GeometryReader { geo in ZStack(alignment: .topLeading) { #if canImport(UIKit) Image(uiImage: image).resizable().aspectRatio(contentMode: .fit) #else Image(nsImage: image).resizable().aspectRatio(contentMode: .fit) #endif ForEach(regions) { region in overlayRect(for: region, in: geo.size) } if let dragStart, let dragEnd { let rect = normalizedRect(from: dragStart, to: dragEnd) Rectangle() .stroke(CardsTheme.warning, lineWidth: 2) .background(Rectangle().fill(CardsTheme.warning.opacity(0.2))) .frame(width: rect.width, height: rect.height) .offset(x: rect.minX, y: rect.minY) } } .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 4) .onChanged { value in if dragStart == nil { dragStart = value.startLocation } dragEnd = value.location } .onEnded { value in commitDrag(start: value.startLocation, end: value.location, in: geo.size) } ) } } private func overlayRect(for region: MaskRegion, in size: CGSize) -> some View { Rectangle() .fill(CardsTheme.primary.opacity(0.6)) .frame(width: region.w * size.width, height: region.h * size.height) .offset(x: region.x * size.width, y: region.y * size.height) .overlay( Text(region.label?.isEmpty == false ? region.label! : region.id) .font(.caption2.weight(.bold)) .foregroundStyle(CardsTheme.primaryForeground) .padding(2) .offset(x: region.x * size.width + 2, y: region.y * size.height + 2), alignment: .topLeading ) } private func maskRow(region: MaskRegion) -> some View { HStack(spacing: 8) { Image(systemName: "square.dashed") .foregroundStyle(CardsTheme.primary) TextField("Label (optional)", text: Binding( get: { region.label ?? "" }, set: { newValue in updateLabel(for: region.id, to: newValue) } )) .textFieldStyle(.roundedBorder) Button(role: .destructive) { regions.removeAll { $0.id == region.id } } label: { Image(systemName: "trash") .foregroundStyle(CardsTheme.error) } .buttonStyle(.plain) } } private func updateLabel(for id: String, to value: String) { guard let idx = regions.firstIndex(where: { $0.id == id }) else { return } let old = regions[idx] regions[idx] = MaskRegion(id: old.id, x: old.x, y: old.y, w: old.w, h: old.h, label: value) } private func normalizedRect(from start: CGPoint, to end: CGPoint) -> CGRect { let x = min(start.x, end.x) let y = min(start.y, end.y) let w = abs(end.x - start.x) let h = abs(end.y - start.y) return CGRect(x: x, y: y, width: w, height: h) } private func commitDrag(start: CGPoint, end: CGPoint, in size: CGSize) { defer { dragStart = nil dragEnd = nil } let rect = normalizedRect(from: start, to: end) // Mindestgröße 1% der Bildkante — Tap-Klicks ignorieren guard rect.width > size.width * 0.01, rect.height > size.height * 0.01 else { return } nextIdCounter += 1 let id = String(format: "m%03d", nextIdCounter) let normalized = MaskRegion( id: id, x: rect.minX / size.width, y: rect.minY / size.height, w: rect.width / size.width, h: rect.height / size.height, label: nil ) regions.append(normalized) } }