// // MainComposeTextView.swift // Tusker // // Created by Shadowfacts on 8/29/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import SwiftUI import Pachyderm struct MainComposeTextView: View, PlaceholderViewProvider { @ObservedObject var draft: OldDraft @State private var placeholder: PlaceholderView = Self.placeholderView() let minHeight: CGFloat = 150 @State private var height: CGFloat? @Binding var becomeFirstResponder: Bool @State private var hasFirstAppeared = false @ScaledMetric private var fontSize = 20 @Environment(\.colorScheme) private var colorScheme var body: some View { ZStack(alignment: .topLeading) { colorScheme == .dark ? Color.appFill : Color(uiColor: .secondarySystemBackground) if draft.text.isEmpty { placeholder .font(.system(size: fontSize)) .foregroundColor(.secondary) .offset(x: 4, y: 8) .accessibilityHidden(true) } MainComposeWrappedTextView( text: $draft.text, visibility: draft.visibility, becomeFirstResponder: $becomeFirstResponder ) { (textView) in self.height = max(textView.contentSize.height, minHeight) } } .frame(height: height ?? minHeight) .onAppear { if !hasFirstAppeared { hasFirstAppeared = true becomeFirstResponder = true } } } @ViewBuilder static func placeholderView() -> some View { let components = Calendar.current.dateComponents([.month, .day], from: Date()) if components.month == 3 && components.day == 14, Date().formatted(date: .numeric, time: .omitted).starts(with: "3") { Text("Happy π day!") } else if components.month == 4 && components.day == 1 { Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center) } else if components.month == 9 && components.day == 5 { // https://weirder.earth/@noracodes/109276419847254552 // https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990 Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic() } else if components.month == 9 && components.day == 21 { Text("Do you remember?") } else if components.month == 10 && components.day == 31 { if .random() { Text("Post something spooky!") } else { Text("Any questions?") } } else { Text("What's on your mind?") } } } // exists to provide access to the type alias since the @State property needs it to be explicit private protocol PlaceholderViewProvider { associatedtype PlaceholderView: View @ViewBuilder static func placeholderView() -> PlaceholderView } struct MainComposeWrappedTextView: UIViewRepresentable { typealias UIViewType = UITextView @Binding var text: String let visibility: Pachyderm.Visibility @Binding var becomeFirstResponder: Bool var textDidChange: (UITextView) -> Void @EnvironmentObject var uiState: ComposeUIState @EnvironmentObject var mastodonController: MastodonController @ObservedObject var preferences = Preferences.shared @Environment(\.isEnabled) var isEnabled: Bool func makeUIView(context: Context) -> UITextView { let textView = WrappedTextView(uiState: uiState) textView.delegate = context.coordinator textView.isEditable = true textView.backgroundColor = .clear textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 20)) textView.adjustsFontForContentSizeCategory = true textView.textContainer.lineBreakMode = .byWordWrapping context.coordinator.textView = textView return textView } func updateUIView(_ uiView: UITextView, context: Context) { if context.coordinator.skipSettingTextOnNextUpdate { context.coordinator.skipSettingTextOnNextUpdate = false } else { context.coordinator.skipNextAutocompleteUpdate = true uiView.text = text } uiView.isEditable = isEnabled uiView.keyboardType = preferences.useTwitterKeyboard ? .twitter : .default context.coordinator.text = $text context.coordinator.didChange = textDidChange context.coordinator.uiState = uiState // wait until the next runloop iteration so that SwiftUI view updates have finished and // the text view knows its new content size DispatchQueue.main.async { self.textDidChange(uiView) if becomeFirstResponder { // calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13 uiView.becomeFirstResponder() // can't update @State vars during the SwiftUI update becomeFirstResponder = false } } } func makeCoordinator() -> Coordinator { return Coordinator(text: $text, uiState: uiState, didChange: textDidChange) } class WrappedTextView: UITextView { private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))] unowned var uiState: ComposeUIState init(uiState: ComposeUIState) { self.uiState = uiState super.init(frame: .zero, textContainer: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if formattingActions.contains(action) { return Preferences.shared.statusContentType != .plain } return super.canPerformAction(action, withSender: sender) } override func toggleBoldface(_ sender: Any?) { (delegate as! Coordinator).applyFormat(.bold) } override func toggleItalics(_ sender: Any?) { (delegate as! Coordinator).applyFormat(.italics) } override func validate(_ command: UICommand) { super.validate(command) if formattingActions.contains(command.action), Preferences.shared.statusContentType != .plain { command.attributes.remove(.disabled) } } override func paste(_ sender: Any?) { // we deliberately exclude the other CompositionAttachment readable type identifiers, because that's too overzealous with the conversion // and things like URLs end up pasting as attachments if UIPasteboard.general.contains(pasteboardTypes: UIImage.readableTypeIdentifiersForItemProvider) { uiState.delegate?.paste(itemProviders: UIPasteboard.general.itemProviders) } else { super.paste(sender) } } } class Coordinator: NSObject, UITextViewDelegate, ComposeInput, ComposeTextViewCaretScrolling { weak var textView: UITextView? var text: Binding var didChange: (UITextView) -> Void // break retained cycle through ComposeUIState.currentInput unowned var uiState: ComposeUIState var caretScrollPositionAnimator: UIViewPropertyAnimator? var skipSettingTextOnNextUpdate = false var skipNextAutocompleteUpdate = false var toolbarElements: [ComposeUIState.ToolbarElement] { [.emojiPicker, .formattingButtons] } init(text: Binding, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) { self.text = text self.didChange = didChange self.uiState = uiState super.init() NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil) } @objc private func keyboardDidShow() { guard let textView, textView.isFirstResponder else { return } ensureCursorVisible(textView: textView) } func textViewDidChange(_ textView: UITextView) { text.wrappedValue = textView.text didChange(textView) ensureCursorVisible(textView: textView) } func applyFormat(_ format: StatusFormat) { guard let textView = textView, textView.isFirstResponder, let insertionResult = format.insertionResult else { return } let currentSelectedRange = textView.selectedRange if currentSelectedRange.length == 0 { textView.insertText(insertionResult.prefix + insertionResult.suffix) textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0) } else { let start = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.lowerBound) let end = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.upperBound) let selectedText = textView.text[start.. UIMenu? { var actions = suggestedActions if Preferences.shared.statusContentType != .plain, let index = suggestedActions.firstIndex(where: { ($0 as? UIMenu)?.identifier.rawValue == "com.apple.menu.format" }) { if range.length > 0 { let formatMenu = suggestedActions[index] as! UIMenu let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in var image: UIImage? if let imageName = fmt.imageName { image = UIImage(systemName: imageName) } return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in self?.applyFormat(fmt) } }) actions[index] = newFormatMenu } else { actions.remove(at: index) } } if range.length == 0 { actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in self?.uiState.shouldEmojiAutocompletionBeginExpanded = true self?.beginAutocompletingEmoji() })) } return UIMenu(children: actions) } func beginAutocompletingEmoji() { guard let textView = textView else { return } var insertSpace = false if let text = textView.text, textView.selectedRange.upperBound > 0 { let characterBeforeCursorIndex = text.utf16.index(before: text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)) insertSpace = !text[characterBeforeCursorIndex].isWhitespace } textView.insertText((insertSpace ? " " : "") + ":") } func autocomplete(with string: String) { guard let textView = textView, let text = textView.text, let (lastWordStartIndex, _) = findAutocompleteLastWord() else { return } let distanceToEnd = text.utf16.count - textView.selectedRange.upperBound let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound) let insertSpace: Bool if distanceToEnd > 0 { let charAfterCursor = text[characterBeforeCursorIndex] insertSpace = charAfterCursor != " " && charAfterCursor != "\n" } else { insertSpace = true } let string = insertSpace ? string + " " : string textView.text.replaceSubrange(lastWordStartIndex.. text.startIndex { // if the character before the "word" beginning is a valid part of a "word", // we aren't able to autocomplete let c = text[text.index(before: lastWordStartIndex)] if isPermittedForAutocomplete(c) || triggerChars.contains(c) { uiState.autocompleteState = nil return } } let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound) if lastWordStartIndex >= text.startIndex { let lastWord = text[lastWordStartIndex.. Bool { return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_" } private func findAutocompleteLastWord() -> (index: String.Index, foundFirstAtSign: Bool)? { guard let textView = textView, textView.isFirstResponder, textView.selectedRange.length == 0, textView.selectedRange.upperBound > 0, let text = textView.text, text.count > 0 else { return nil } let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound) var lastWordStartIndex = text.index(before: characterBeforeCursorIndex) var foundFirstAtSign = false while true { let c = text[lastWordStartIndex] if !isPermittedForAutocomplete(c) { if foundFirstAtSign { if c != "@" { // move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it lastWordStartIndex = text.index(after: lastWordStartIndex) } break } else { if c == "@" { foundFirstAtSign = true } else if c != "." { // periods are allowed for domain names in mentions break } } } if lastWordStartIndex > text.startIndex { lastWordStartIndex = text.index(before: lastWordStartIndex) } else { break } } return (lastWordStartIndex, foundFirstAtSign) } } }