// // AttachmentRowController.swift // ComposeUI // // Created by Shadowfacts on 3/12/23. // import SwiftUI import TuskerComponents import Vision import MatchedGeometryPresentation class AttachmentRowController: ViewController { let parent: ComposeController let attachment: DraftAttachment @Published var descriptionMode: DescriptionMode = .allowEntry @Published var textRecognitionError: Error? @Published var focusAttachmentOnTextEditorUnfocus = false let thumbnailController: AttachmentThumbnailController private var descriptionObservation: NSKeyValueObservation? init(parent: ComposeController, attachment: DraftAttachment) { self.parent = parent self.attachment = attachment self.thumbnailController = AttachmentThumbnailController(attachment: attachment, parent: parent) descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in // the faultingState is non-zero for objects that are being cascade deleted when the draft is deleted if attachment.faultingState == 0 { self.updateAttachmentDescriptionState() } }) } private func updateAttachmentDescriptionState() { if attachment.attachmentDescription.isEmpty { parent.attachmentsMissingDescriptions.insert(attachment.id) } else { parent.attachmentsMissingDescriptions.remove(attachment.id) } } var view: some View { AttachmentView(attachment: attachment) } private func removeAttachment() { withAnimation { parent.draft.attachments.remove(attachment) } } private func editDrawing() { guard case .drawing(let drawing) = attachment.data else { return } parent.config.presentDrawing?(drawing) { newDrawing in self.attachment.drawing = newDrawing } } private func focusAttachment() { focusAttachmentOnTextEditorUnfocus = false parent.focusedAttachment = (attachment, thumbnailController) } private func recognizeText() { descriptionMode = .recognizingText DispatchQueue.global(qos: .userInitiated).async { self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in let data: Data switch result { case .success((let d, _)): data = d case .failure(let error): self.descriptionMode = .allowEntry self.textRecognitionError = error return } let handler = VNImageRequestHandler(data: data) let request = VNRecognizeTextRequest { request, error in DispatchQueue.main.async { if let results = request.results as? [VNRecognizedTextObservation] { var text = "" for observation in results { let result = observation.topCandidates(1).first! text.append(result.string) text.append("\n") } self.attachment.attachmentDescription = text } self.descriptionMode = .allowEntry } } request.recognitionLevel = .accurate request.usesLanguageCorrection = true DispatchQueue.global(qos: .userInitiated).async { do { try handler.perform([request]) } catch let error as NSError where error.code == 1 { // The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for. return } catch { DispatchQueue.main.async { self.descriptionMode = .allowEntry self.textRecognitionError = error } } } } } } struct AttachmentView: View { @ObservedObject private var attachment: DraftAttachment @EnvironmentObject private var controller: AttachmentRowController @FocusState private var textEditorFocused: Bool init(attachment: DraftAttachment) { self.attachment = attachment } var body: some View { HStack(alignment: .center, spacing: 4) { ControllerView(controller: { controller.thumbnailController }) .clipShape(RoundedRectangle(cornerRadius: 8)) .environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: false)) .matchedGeometrySource(id: attachment.id, presentationID: attachment.id) .overlay { thumbnailFocusedOverlay } .frame(width: 80, height: 80) .onTapGesture { textEditorFocused = false // if we just focus the attachment immediately, the text editor doesn't actually unfocus controller.focusAttachmentOnTextEditorUnfocus = true } .contextMenu { if attachment.drawingData != nil { Button(action: controller.editDrawing) { Label("Edit Drawing", systemImage: "hand.draw") } } else if attachment.type == .image { Button(action: controller.recognizeText) { Label("Recognize Text", systemImage: "doc.text.viewfinder") } } Button(role: .destructive, action: controller.removeAttachment) { Label("Delete", systemImage: "trash") } } previewIfAvailable: { ControllerView(controller: { controller.thumbnailController }) } switch controller.descriptionMode { case .allowEntry: InlineAttachmentDescriptionView(attachment: attachment, minHeight: 80) .matchedGeometrySource(id: AttachmentDescriptionTextViewID(attachment), presentationID: attachment.id) .focused($textEditorFocused) case .recognizingText: ProgressView() .progressViewStyle(.circular) } } .alertWithData("Text Recognition Failed", data: $controller.textRecognitionError) { _ in Button("OK") {} } message: { error in Text(error.localizedDescription) } .onAppear(perform: controller.updateAttachmentDescriptionState) .onChange(of: textEditorFocused) { newValue in if !newValue && controller.focusAttachmentOnTextEditorUnfocus { controller.focusAttachment() } } } @ViewBuilder private var thumbnailFocusedOverlay: some View { Image(systemName: "arrow.up.backward.and.arrow.down.forward") .foregroundColor(.white) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.black.opacity(0.35)) .clipShape(RoundedRectangle(cornerRadius: 8)) // use .opacity and an animation, because .transition doesn't seem to play nice with @FocusState .opacity(textEditorFocused ? 1 : 0) .animation(.linear(duration: 0.1), value: textEditorFocused) } } } extension AttachmentRowController { enum DescriptionMode { case allowEntry, recognizingText } } private extension View { @available(iOS, obsoleted: 16.0) @ViewBuilder func contextMenu(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View { if #available(iOS 16.0, *) { self.contextMenu(menuItems: menuItems, preview: preview) } else { self.contextMenu(menuItems: menuItems) } } }