Integrate services with UI via EditorViewModel
- Add EditorViewModel coordinating masking, contour detection, and inpainting - Connect PhotoEditorView to viewModel with mask confirmation bar - Add processing overlay and error toast UI - Update CanvasView with tap-to-mask functionality - Add coordinate conversion from view space to image space - Update ToolbarView to bind to viewModel state - Wire up undo/redo actions through viewModel Person, object, and wire removal now flow: tap -> detect -> preview mask -> confirm -> inpaint Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,11 @@
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"Bash(git ls-remote:*)",
|
||||
"Bash(git add:*)"
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nAdd Ralph Wiggum agent setup and project specifications\n\n- Add project constitution with vision, principles, and autonomy settings\n- Add 15 feature specifications covering full app scope\n- Configure agent entry points \\(AGENTS.md, CLAUDE.md\\)\n- Add build prompt and speckit command for spec creation\n- Include comprehensive .gitignore for iOS development\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(./check_build.sh:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nImplement core project structure and foundation components\n\nProject Setup \\(Spec 01\\):\n- Configure iOS 17.0 deployment target\n- Add photo library usage descriptions\n- Create proper folder structure \\(App, Features, Services, Models, Utilities\\)\n\nData Model \\(Spec 02\\):\n- Add EditOperation enum with mask, inpaint, adjustment cases\n- Add MaskOperation, InpaintOperation structs \\(Codable\\)\n- Add Project model with operation stack and undo/redo support\n- Add MaskData with dilation support via vImage\n\nServices \\(Specs 03-05\\):\n- Add InpaintEngine with Metal + Accelerate fallback\n- Add MaskingService wrapping Vision framework\n- Add ContourService for wire/line detection and scoring\n\nUI Components \\(Specs 06-08\\):\n- Add PhotoEditorView with photo picker integration\n- Add CanvasView with pinch-to-zoom and pan gestures\n- Add ToolbarView with tool selection and inspector panel\n\nUtilities:\n- Add ImagePipeline for preview/export rendering\n- Add EdgeRefinement for smart brush edge detection\n- Add check_build.sh for CI verification\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git push)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct CanvasView: View {
|
||||
let image: UIImage
|
||||
@Bindable var viewModel: EditorViewModel
|
||||
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var lastScale: CGFloat = 1.0
|
||||
@State private var offset: CGSize = .zero
|
||||
@State private var lastOffset: CGSize = .zero
|
||||
@State private var isShowingOriginal = false
|
||||
@State private var viewSize: CGSize = .zero
|
||||
|
||||
private let minScale: CGFloat = 1.0
|
||||
private let maxScale: CGFloat = 10.0
|
||||
@@ -25,12 +26,61 @@ struct CanvasView: View {
|
||||
ZStack {
|
||||
Color.black
|
||||
|
||||
Image(uiImage: image)
|
||||
if let cgImage = isShowingOriginal ? viewModel.originalImage : viewModel.displayImage {
|
||||
// Main image
|
||||
Image(decorative: cgImage, scale: 1.0)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.scaleEffect(scale)
|
||||
.offset(offset)
|
||||
.gesture(
|
||||
|
||||
// Mask overlay
|
||||
if let mask = viewModel.maskPreview {
|
||||
Image(decorative: mask, scale: 1.0)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.scaleEffect(scale)
|
||||
.offset(offset)
|
||||
.blendMode(.multiply)
|
||||
.colorMultiply(.red.opacity(0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.gesture(tapGesture(in: geometry))
|
||||
.gesture(magnificationGesture(in: geometry))
|
||||
.simultaneousGesture(dragGesture(in: geometry))
|
||||
.simultaneousGesture(longPressGesture)
|
||||
.onTapGesture(count: 2) {
|
||||
doubleTapZoom()
|
||||
}
|
||||
.onAppear {
|
||||
viewSize = geometry.size
|
||||
}
|
||||
.onChange(of: geometry.size) { _, newSize in
|
||||
viewSize = newSize
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
|
||||
// MARK: - Gestures
|
||||
|
||||
private func tapGesture(in geometry: GeometryProxy) -> some Gesture {
|
||||
SpatialTapGesture()
|
||||
.onEnded { value in
|
||||
guard !viewModel.isProcessing,
|
||||
!viewModel.showingMaskConfirmation,
|
||||
viewModel.selectedTool != .brush else { return }
|
||||
|
||||
let imagePoint = convertViewPointToImagePoint(value.location, in: geometry.size)
|
||||
Task {
|
||||
await viewModel.handleTap(at: imagePoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func magnificationGesture(in geometry: GeometryProxy) -> some Gesture {
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
let newScale = lastScale * value
|
||||
@@ -42,8 +92,9 @@ struct CanvasView: View {
|
||||
clampOffset(in: geometry.size)
|
||||
}
|
||||
}
|
||||
)
|
||||
.simultaneousGesture(
|
||||
}
|
||||
|
||||
private func dragGesture(in geometry: GeometryProxy) -> some Gesture {
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
if scale > 1.0 {
|
||||
@@ -59,17 +110,21 @@ struct CanvasView: View {
|
||||
clampOffset(in: geometry.size)
|
||||
}
|
||||
}
|
||||
)
|
||||
.simultaneousGesture(
|
||||
LongPressGesture(minimumDuration: 0.2)
|
||||
.onChanged { _ in
|
||||
isShowingOriginal = true
|
||||
}
|
||||
|
||||
private var longPressGesture: some Gesture {
|
||||
LongPressGesture(minimumDuration: 0.3)
|
||||
.onEnded { _ in
|
||||
// Toggle to show original briefly
|
||||
isShowingOriginal = true
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(0.5))
|
||||
isShowingOriginal = false
|
||||
}
|
||||
)
|
||||
.onTapGesture(count: 2) {
|
||||
}
|
||||
}
|
||||
|
||||
private func doubleTapZoom() {
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
if scale > 1.0 {
|
||||
scale = 1.0
|
||||
@@ -82,10 +137,58 @@ struct CanvasView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinate Conversion
|
||||
|
||||
private func convertViewPointToImagePoint(_ viewPoint: CGPoint, in viewSize: CGSize) -> CGPoint {
|
||||
let imageSize = viewModel.imageSize
|
||||
guard imageSize.width > 0, imageSize.height > 0 else { return viewPoint }
|
||||
|
||||
// Calculate the displayed image frame
|
||||
let imageAspect = imageSize.width / imageSize.height
|
||||
let viewAspect = viewSize.width / viewSize.height
|
||||
|
||||
let displayedSize: CGSize
|
||||
if imageAspect > viewAspect {
|
||||
displayedSize = CGSize(
|
||||
width: viewSize.width,
|
||||
height: viewSize.width / imageAspect
|
||||
)
|
||||
} else {
|
||||
displayedSize = CGSize(
|
||||
width: viewSize.height * imageAspect,
|
||||
height: viewSize.height
|
||||
)
|
||||
}
|
||||
|
||||
// Account for centering
|
||||
let displayedOrigin = CGPoint(
|
||||
x: (viewSize.width - displayedSize.width) / 2,
|
||||
y: (viewSize.height - displayedSize.height) / 2
|
||||
)
|
||||
|
||||
// Account for scale and offset
|
||||
let scaledSize = CGSize(
|
||||
width: displayedSize.width * scale,
|
||||
height: displayedSize.height * scale
|
||||
)
|
||||
|
||||
let scaledOrigin = CGPoint(
|
||||
x: (viewSize.width - scaledSize.width) / 2 + offset.width,
|
||||
y: (viewSize.height - scaledSize.height) / 2 + offset.height
|
||||
)
|
||||
|
||||
// Convert view point to image coordinates
|
||||
let relativeX = (viewPoint.x - scaledOrigin.x) / scaledSize.width
|
||||
let relativeY = (viewPoint.y - scaledOrigin.y) / scaledSize.height
|
||||
|
||||
return CGPoint(
|
||||
x: relativeX * imageSize.width,
|
||||
y: relativeY * imageSize.height
|
||||
)
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func clampOffset(in size: CGSize) {
|
||||
guard scale > 1.0 else {
|
||||
@@ -94,7 +197,10 @@ struct CanvasView: View {
|
||||
return
|
||||
}
|
||||
|
||||
let imageAspect = image.size.width / image.size.height
|
||||
let imageSize = viewModel.imageSize
|
||||
guard imageSize.width > 0, imageSize.height > 0 else { return }
|
||||
|
||||
let imageAspect = imageSize.width / imageSize.height
|
||||
let viewAspect = size.width / size.height
|
||||
|
||||
let imageDisplaySize: CGSize
|
||||
@@ -125,5 +231,6 @@ struct CanvasView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CanvasView(image: UIImage(systemName: "photo")!)
|
||||
let viewModel = EditorViewModel()
|
||||
return CanvasView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
249
CheapRetouch/Features/Editor/EditorViewModel.swift
Normal file
249
CheapRetouch/Features/Editor/EditorViewModel.swift
Normal file
@@ -0,0 +1,249 @@
|
||||
//
|
||||
// EditorViewModel.swift
|
||||
// CheapRetouch
|
||||
//
|
||||
// View model coordinating edit operations between services and UI.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import CoreGraphics
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class EditorViewModel {
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var originalImage: CGImage?
|
||||
var editedImage: CGImage?
|
||||
var maskPreview: CGImage?
|
||||
var selectedTool: EditTool = .person
|
||||
var brushSize: CGFloat = 20
|
||||
var featherAmount: CGFloat = 4
|
||||
var wireWidth: CGFloat = 6
|
||||
|
||||
var isProcessing = false
|
||||
var processingMessage = ""
|
||||
var errorMessage: String?
|
||||
var showingMaskConfirmation = false
|
||||
|
||||
private(set) var project: Project?
|
||||
|
||||
// MARK: - Services
|
||||
|
||||
private let maskingService = MaskingService()
|
||||
private let contourService = ContourService()
|
||||
private let inpaintEngine = InpaintEngine()
|
||||
private let imagePipeline = ImagePipeline()
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var displayImage: CGImage? {
|
||||
editedImage ?? originalImage
|
||||
}
|
||||
|
||||
var canUndo: Bool {
|
||||
project?.canUndo ?? false
|
||||
}
|
||||
|
||||
var canRedo: Bool {
|
||||
project?.canRedo ?? false
|
||||
}
|
||||
|
||||
var imageSize: CGSize {
|
||||
guard let image = originalImage else { return .zero }
|
||||
return CGSize(width: image.width, height: image.height)
|
||||
}
|
||||
|
||||
// MARK: - Image Loading
|
||||
|
||||
func loadImage(_ uiImage: UIImage) {
|
||||
guard let cgImage = uiImage.cgImage else { return }
|
||||
|
||||
originalImage = cgImage
|
||||
editedImage = nil
|
||||
maskPreview = nil
|
||||
errorMessage = nil
|
||||
|
||||
// Create new project
|
||||
let imageData = uiImage.jpegData(compressionQuality: 0.9) ?? Data()
|
||||
project = Project(imageSource: .embedded(data: imageData))
|
||||
}
|
||||
|
||||
// MARK: - Tap Handling
|
||||
|
||||
func handleTap(at point: CGPoint) async {
|
||||
guard let image = displayImage else { return }
|
||||
|
||||
isProcessing = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
switch selectedTool {
|
||||
case .person:
|
||||
processingMessage = "Detecting person..."
|
||||
try await handlePersonTap(at: point, in: image)
|
||||
|
||||
case .object:
|
||||
processingMessage = "Detecting object..."
|
||||
try await handleObjectTap(at: point, in: image)
|
||||
|
||||
case .wire:
|
||||
processingMessage = "Detecting wire..."
|
||||
try await handleWireTap(at: point, in: image)
|
||||
|
||||
case .brush:
|
||||
// Brush tool handles drawing directly in CanvasView
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isProcessing = false
|
||||
processingMessage = ""
|
||||
}
|
||||
|
||||
private func handlePersonTap(at point: CGPoint, in image: CGImage) async throws {
|
||||
let mask = try await maskingService.generatePersonMask(at: point, in: image)
|
||||
|
||||
guard let mask = mask else {
|
||||
errorMessage = "No person found at tap location"
|
||||
return
|
||||
}
|
||||
|
||||
maskPreview = mask
|
||||
showingMaskConfirmation = true
|
||||
}
|
||||
|
||||
private func handleObjectTap(at point: CGPoint, in image: CGImage) async throws {
|
||||
let mask = try await maskingService.generateForegroundMask(at: point, in: image)
|
||||
|
||||
guard let mask = mask else {
|
||||
errorMessage = "Couldn't detect object. Try the brush tool to select manually."
|
||||
return
|
||||
}
|
||||
|
||||
maskPreview = mask
|
||||
showingMaskConfirmation = true
|
||||
}
|
||||
|
||||
private func handleWireTap(at point: CGPoint, in image: CGImage) async throws {
|
||||
let contours = try await contourService.detectContours(in: image)
|
||||
let bestContour = await contourService.findBestWireContour(
|
||||
at: point,
|
||||
from: contours,
|
||||
imageSize: CGSize(width: image.width, height: image.height)
|
||||
)
|
||||
|
||||
guard let contour = bestContour else {
|
||||
errorMessage = "No lines detected. Use the line brush to draw along the wire."
|
||||
return
|
||||
}
|
||||
|
||||
let mask = await contourService.contourToMask(
|
||||
contour,
|
||||
width: Int(wireWidth),
|
||||
imageSize: CGSize(width: image.width, height: image.height)
|
||||
)
|
||||
|
||||
guard let mask = mask else {
|
||||
errorMessage = "Failed to create mask from contour"
|
||||
return
|
||||
}
|
||||
|
||||
maskPreview = mask
|
||||
showingMaskConfirmation = true
|
||||
}
|
||||
|
||||
// MARK: - Mask Confirmation
|
||||
|
||||
func confirmMask() async {
|
||||
guard let image = displayImage, let mask = maskPreview else { return }
|
||||
|
||||
isProcessing = true
|
||||
processingMessage = "Removing..."
|
||||
|
||||
do {
|
||||
let result = try await inpaintEngine.inpaint(image: image, mask: mask)
|
||||
editedImage = result
|
||||
|
||||
// Add operation to project
|
||||
if var project = project {
|
||||
let maskData = MaskData(from: mask)?.data ?? Data()
|
||||
let maskOp = MaskOperation(toolType: toolTypeForCurrentTool(), maskData: maskData)
|
||||
let inpaintOp = InpaintOperation(maskOperationId: maskOp.id, featherAmount: Float(featherAmount))
|
||||
|
||||
project.addOperation(.mask(maskOp))
|
||||
project.addOperation(.inpaint(inpaintOp))
|
||||
self.project = project
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
maskPreview = nil
|
||||
showingMaskConfirmation = false
|
||||
isProcessing = false
|
||||
processingMessage = ""
|
||||
}
|
||||
|
||||
func cancelMask() {
|
||||
maskPreview = nil
|
||||
showingMaskConfirmation = false
|
||||
}
|
||||
|
||||
// MARK: - Undo/Redo
|
||||
|
||||
func undo() async {
|
||||
guard var project = project, project.undo() else { return }
|
||||
self.project = project
|
||||
await rebuildEditedImage()
|
||||
}
|
||||
|
||||
func redo() async {
|
||||
guard var project = project, project.redo() else { return }
|
||||
self.project = project
|
||||
await rebuildEditedImage()
|
||||
}
|
||||
|
||||
private func rebuildEditedImage() async {
|
||||
guard let original = originalImage, let project = project else { return }
|
||||
|
||||
isProcessing = true
|
||||
processingMessage = "Rebuilding..."
|
||||
|
||||
do {
|
||||
let result = try await imagePipeline.renderPreview(
|
||||
originalImage: original,
|
||||
operations: project.activeOperations
|
||||
)
|
||||
editedImage = result
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isProcessing = false
|
||||
processingMessage = ""
|
||||
}
|
||||
|
||||
// MARK: - Brush Tool
|
||||
|
||||
func applyBrushMask(_ mask: CGImage) async {
|
||||
maskPreview = mask
|
||||
await confirmMask()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func toolTypeForCurrentTool() -> ToolType {
|
||||
switch selectedTool {
|
||||
case .person: return .person
|
||||
case .object: return .object
|
||||
case .wire: return .wire
|
||||
case .brush: return .brush
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,24 +10,39 @@ import PhotosUI
|
||||
import UIKit
|
||||
|
||||
struct PhotoEditorView: View {
|
||||
@State private var selectedImage: UIImage?
|
||||
@State private var viewModel = EditorViewModel()
|
||||
@State private var selectedItem: PhotosPickerItem?
|
||||
@State private var isShowingPicker = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color(.systemBackground)
|
||||
Color.black
|
||||
.ignoresSafeArea()
|
||||
|
||||
if let image = selectedImage {
|
||||
if viewModel.originalImage != nil {
|
||||
VStack(spacing: 0) {
|
||||
CanvasView(image: image)
|
||||
ToolbarView()
|
||||
CanvasView(viewModel: viewModel)
|
||||
|
||||
if viewModel.showingMaskConfirmation {
|
||||
maskConfirmationBar
|
||||
}
|
||||
|
||||
ToolbarView(viewModel: viewModel)
|
||||
}
|
||||
} else {
|
||||
EmptyStateView(isShowingPicker: $isShowingPicker)
|
||||
}
|
||||
|
||||
// Processing overlay
|
||||
if viewModel.isProcessing {
|
||||
processingOverlay
|
||||
}
|
||||
|
||||
// Error toast
|
||||
if let error = viewModel.errorMessage {
|
||||
errorToast(message: error)
|
||||
}
|
||||
}
|
||||
.navigationTitle("CheapRetouch")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -36,19 +51,98 @@ struct PhotoEditorView: View {
|
||||
PhotosPicker(selection: $selectedItem, matching: .images) {
|
||||
Image(systemName: "photo.on.rectangle")
|
||||
}
|
||||
.disabled(viewModel.isProcessing)
|
||||
}
|
||||
|
||||
if viewModel.originalImage != nil {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
// Export action
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
.disabled(viewModel.isProcessing || viewModel.editedImage == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedItem) { oldValue, newValue in
|
||||
Task {
|
||||
if let data = try? await newValue?.loadTransferable(type: Data.self),
|
||||
let uiImage = UIImage(data: data) {
|
||||
selectedImage = uiImage
|
||||
viewModel.loadImage(uiImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
.photosPicker(isPresented: $isShowingPicker, selection: $selectedItem, matching: .images)
|
||||
}
|
||||
}
|
||||
|
||||
private var maskConfirmationBar: some View {
|
||||
HStack(spacing: 20) {
|
||||
Button {
|
||||
viewModel.cancelMask()
|
||||
} label: {
|
||||
Label("Cancel", systemImage: "xmark")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.confirmMask()
|
||||
}
|
||||
} label: {
|
||||
Label("Remove", systemImage: "checkmark")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
private var processingOverlay: some View {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
|
||||
if !viewModel.processingMessage.isEmpty {
|
||||
Text(viewModel.processingMessage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private func errorToast(message: String) -> some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding()
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
.padding()
|
||||
.padding(.bottom, 80)
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.onAppear {
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
viewModel.errorMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyStateView: View {
|
||||
|
||||
@@ -24,7 +24,7 @@ enum EditTool: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
var toolDescription: String {
|
||||
switch self {
|
||||
case .person: return "Tap to remove people"
|
||||
case .object: return "Tap to remove objects"
|
||||
@@ -35,19 +35,14 @@ enum EditTool: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
struct ToolbarView: View {
|
||||
@State private var selectedTool: EditTool = .person
|
||||
@State private var brushSize: Double = 20
|
||||
@State private var featherAmount: Double = 4
|
||||
@State private var wireWidth: Double = 6
|
||||
@State private var canUndo = false
|
||||
@State private var canRedo = false
|
||||
@Bindable var viewModel: EditorViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
|
||||
// Inspector panel (contextual)
|
||||
if selectedTool == .brush || selectedTool == .wire {
|
||||
if viewModel.selectedTool == .brush || viewModel.selectedTool == .wire {
|
||||
inspectorPanel
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
@@ -63,35 +58,39 @@ struct ToolbarView: View {
|
||||
|
||||
// Undo/Redo
|
||||
Button {
|
||||
// Undo action
|
||||
Task {
|
||||
await viewModel.undo()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.uturn.backward")
|
||||
.font(.title3)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.disabled(!canUndo)
|
||||
.disabled(!viewModel.canUndo || viewModel.isProcessing)
|
||||
.accessibilityLabel("Undo")
|
||||
|
||||
Button {
|
||||
// Redo action
|
||||
Task {
|
||||
await viewModel.redo()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.uturn.forward")
|
||||
.font(.title3)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.disabled(!canRedo)
|
||||
.disabled(!viewModel.canRedo || viewModel.isProcessing)
|
||||
.accessibilityLabel("Redo")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: selectedTool)
|
||||
.animation(.easeInOut(duration: 0.2), value: viewModel.selectedTool)
|
||||
}
|
||||
|
||||
private func toolButton(for tool: EditTool) -> some View {
|
||||
Button {
|
||||
selectedTool = tool
|
||||
viewModel.selectedTool = tool
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: tool.icon)
|
||||
@@ -101,51 +100,52 @@ struct ToolbarView: View {
|
||||
Text(tool.rawValue)
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundStyle(selectedTool == tool ? Color.accentColor : Color.secondary)
|
||||
.foregroundStyle(viewModel.selectedTool == tool ? Color.accentColor : Color.secondary)
|
||||
.frame(width: 60)
|
||||
}
|
||||
.disabled(viewModel.isProcessing)
|
||||
.accessibilityLabel("\(tool.rawValue) tool")
|
||||
.accessibilityHint(tool.description)
|
||||
.accessibilityAddTraits(selectedTool == tool ? .isSelected : [])
|
||||
.accessibilityHint(tool.toolDescription)
|
||||
.accessibilityAddTraits(viewModel.selectedTool == tool ? .isSelected : [])
|
||||
}
|
||||
|
||||
private var inspectorPanel: some View {
|
||||
VStack(spacing: 12) {
|
||||
Divider()
|
||||
|
||||
if selectedTool == .brush {
|
||||
if viewModel.selectedTool == .brush {
|
||||
HStack {
|
||||
Text("Brush Size")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text("\(Int(brushSize))px")
|
||||
Text("\(Int(viewModel.brushSize))px")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Slider(value: $brushSize, in: 1...100, step: 1)
|
||||
Slider(value: $viewModel.brushSize, in: 1...100, step: 1)
|
||||
.accessibilityLabel("Brush size slider")
|
||||
|
||||
Stepper("", value: $brushSize, in: 1...100, step: 1)
|
||||
Stepper("", value: $viewModel.brushSize, in: 1...100, step: 1)
|
||||
.labelsHidden()
|
||||
.accessibilityLabel("Brush size stepper")
|
||||
}
|
||||
}
|
||||
|
||||
if selectedTool == .wire {
|
||||
if viewModel.selectedTool == .wire {
|
||||
HStack {
|
||||
Text("Line Width")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text("\(Int(wireWidth))px")
|
||||
Text("\(Int(viewModel.wireWidth))px")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
Slider(value: $wireWidth, in: 2...20, step: 1)
|
||||
Slider(value: $viewModel.wireWidth, in: 2...20, step: 1)
|
||||
.accessibilityLabel("Wire width slider")
|
||||
}
|
||||
|
||||
@@ -153,13 +153,13 @@ struct ToolbarView: View {
|
||||
Text("Feather")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text("\(Int(featherAmount))px")
|
||||
Text("\(Int(viewModel.featherAmount))px")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
Slider(value: $featherAmount, in: 0...20, step: 1)
|
||||
Slider(value: $viewModel.featherAmount, in: 0...20, step: 1)
|
||||
.accessibilityLabel("Feather amount slider")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
@@ -169,8 +169,9 @@ struct ToolbarView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack {
|
||||
let viewModel = EditorViewModel()
|
||||
return VStack {
|
||||
Spacer()
|
||||
ToolbarView()
|
||||
ToolbarView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user