Tusker/Tusker/Screens/Compose/MainComposeTextView.swift

444 lines
18 KiB
Swift

//
// 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<String>
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<String>, 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..<end]
textView.insertText(String(insertionResult.prefix + selectedText + insertionResult.suffix))
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.prefix.utf16.count, length: currentSelectedRange.length)
}
}
func textViewDidBeginEditing(_ textView: UITextView) {
uiState.currentInput = self
updateAutocompleteState()
}
func textViewDidEndEditing(_ textView: UITextView) {
uiState.currentInput = nil
updateAutocompleteState()
}
func textViewDidChangeSelection(_ textView: UITextView) {
// Setting the text view's text causes it to move the cursor to the end (though only
// when the text contains an emoji :/), so skip setting the text on the next SwiftUI update
// that's triggered by setting the autocomplete state.
skipSettingTextOnNextUpdate = true
self.updateAutocompleteState()
}
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> 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..<characterBeforeCursorIndex, with: string)
self.textViewDidChange(textView)
self.updateAutocompleteState()
// keep the cursor at the same position in the text, immediately after what was inserted
// if we inserted a space, move the cursor 1 farther so it's immediately after the pre-existing space
let insertSpaceOffset = insertSpace ? 0 : 1
textView.selectedRange = NSRange(location: textView.text.utf16.count - distanceToEnd + insertSpaceOffset, length: 0)
}
private func updateAutocompleteState() {
guard !skipNextAutocompleteUpdate else {
skipNextAutocompleteUpdate = false
return
}
guard let textView = textView,
let text = textView.text,
let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else {
uiState.autocompleteState = nil
return
}
let triggerChars: [Character] = ["@", ":", "#"]
if 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..<characterBeforeCursorIndex]
let exceptFirst = lastWord[lastWord.index(after: lastWord.startIndex)...]
// periods are only allowed in mentions in the domain part
if lastWord.contains(".") {
if lastWord.first == "@" && foundFirstAtSign {
uiState.autocompleteState = .mention(String(exceptFirst))
} else {
uiState.autocompleteState = nil
}
return
}
switch lastWord.first {
case "@":
uiState.autocompleteState = .mention(String(exceptFirst))
case ":":
uiState.autocompleteState = .emoji(String(exceptFirst))
case "#":
uiState.autocompleteState = .hashtag(String(exceptFirst))
default:
uiState.autocompleteState = nil
}
} else {
uiState.autocompleteState = nil
}
}
private func isPermittedForAutocomplete(_ c: Character) -> 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)
}
}
}