405 lines
17 KiB
Swift
405 lines
17 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 {
|
|
@ObservedObject var draft: Draft
|
|
@State private var placeholder: Text = {
|
|
let components = Calendar.current.dateComponents([.month, .day], from: Date())
|
|
if components.month == 3 && components.day == 14 {
|
|
if Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
|
|
return Text("Happy π day!")
|
|
}
|
|
} 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
|
|
return 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 {
|
|
return Text("Do you remember?")
|
|
} else if components.month == 10 && components.day == 31 {
|
|
if .random() {
|
|
return Text("Post something spooky!")
|
|
} else {
|
|
return Text("Any questions?")
|
|
}
|
|
}
|
|
return Text("What's on your mind?")
|
|
}()
|
|
|
|
let minHeight: CGFloat = 150
|
|
@State private var height: CGFloat?
|
|
@Binding var becomeFirstResponder: Bool
|
|
@State private var hasFirstAppeared = false
|
|
@ScaledMetric private var fontSize = 20
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topLeading) {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MainComposeWrappedTextView: UIViewRepresentable {
|
|
typealias UIViewType = UITextView
|
|
|
|
@Binding var text: String
|
|
let visibility: Status.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()
|
|
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 {
|
|
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(_:))]
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
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 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 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)
|
|
}
|
|
}
|
|
}
|