forked from shadowfacts/Tusker
337 lines
13 KiB
Swift
337 lines
13 KiB
Swift
//
|
|
// MainTextView.swift
|
|
// ComposeUI
|
|
//
|
|
// Created by Shadowfacts on 3/6/23.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct MainTextView: View {
|
|
@EnvironmentObject private var controller: ComposeController
|
|
@EnvironmentObject private var draft: Draft
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@ScaledMetric private var fontSize = 20
|
|
|
|
@State private var hasFirstAppeared = false
|
|
@State private var height: CGFloat?
|
|
@State private var updateSelection: ((UITextView) -> Void)?
|
|
private let minHeight: CGFloat = 150
|
|
private var effectiveHeight: CGFloat { height ?? minHeight }
|
|
|
|
var config: ComposeUIConfig {
|
|
controller.config
|
|
}
|
|
|
|
private var placeholderOffset: CGSize {
|
|
#if os(visionOS)
|
|
CGSize(width: 8, height: 8)
|
|
#else
|
|
CGSize(width: 4, height: 8)
|
|
#endif
|
|
}
|
|
|
|
private var textViewBackgroundColor: UIColor? {
|
|
#if os(visionOS)
|
|
nil
|
|
#else
|
|
colorScheme == .dark ? UIColor(config.fillColor) : .secondarySystemBackground
|
|
#endif
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topLeading) {
|
|
MainWrappedTextViewRepresentable(
|
|
text: $draft.text,
|
|
backgroundColor: textViewBackgroundColor,
|
|
becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder,
|
|
updateSelection: $updateSelection,
|
|
textDidChange: textDidChange
|
|
)
|
|
|
|
if draft.text.isEmpty {
|
|
ControllerView(controller: { PlaceholderController() })
|
|
.font(.system(size: fontSize))
|
|
.foregroundColor(.secondary)
|
|
.offset(placeholderOffset)
|
|
.accessibilityHidden(true)
|
|
.allowsHitTesting(false)
|
|
}
|
|
|
|
}
|
|
.frame(height: effectiveHeight)
|
|
.onAppear(perform: becomeFirstResponderOnFirstAppearance)
|
|
}
|
|
|
|
private func becomeFirstResponderOnFirstAppearance() {
|
|
if !hasFirstAppeared {
|
|
hasFirstAppeared = true
|
|
controller.mainComposeTextViewBecomeFirstResponder = true
|
|
if config.textSelectionStartsAtBeginning {
|
|
updateSelection = { textView in
|
|
textView.selectedTextRange = textView.textRange(from: textView.beginningOfDocument, to: textView.beginningOfDocument)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func textDidChange(textView: UITextView) {
|
|
height = max(textView.contentSize.height, minHeight)
|
|
}
|
|
}
|
|
|
|
fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
|
typealias UIViewType = UITextView
|
|
|
|
@Binding var text: String
|
|
let backgroundColor: UIColor?
|
|
@Binding var becomeFirstResponder: Bool
|
|
@Binding var updateSelection: ((UITextView) -> Void)?
|
|
let textDidChange: (UITextView) -> Void
|
|
|
|
@EnvironmentObject private var controller: ComposeController
|
|
@Environment(\.isEnabled) private var isEnabled: Bool
|
|
|
|
func makeUIView(context: Context) -> UITextView {
|
|
let textView = WrappedTextView(composeController: controller)
|
|
context.coordinator.textView = textView
|
|
textView.delegate = context.coordinator
|
|
textView.isEditable = true
|
|
textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20))
|
|
textView.adjustsFontForContentSizeCategory = true
|
|
textView.textContainer.lineBreakMode = .byWordWrapping
|
|
|
|
#if os(visionOS)
|
|
textView.borderStyle = .roundedRect
|
|
// yes, the X inset is 4 less than the placeholder offset
|
|
textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
|
|
#endif
|
|
|
|
return textView
|
|
}
|
|
|
|
func updateUIView(_ uiView: UITextView, context: Context) {
|
|
if text != uiView.text {
|
|
context.coordinator.skipNextSelectionChangedAutocompleteUpdate = true
|
|
uiView.text = text
|
|
}
|
|
|
|
uiView.isEditable = isEnabled
|
|
uiView.keyboardType = controller.config.useTwitterKeyboard ? .twitter : .default
|
|
|
|
uiView.backgroundColor = backgroundColor
|
|
|
|
context.coordinator.text = $text
|
|
|
|
if let updateSelection {
|
|
updateSelection(uiView)
|
|
self.updateSelection = nil
|
|
}
|
|
|
|
// 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 {
|
|
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 {
|
|
Coordinator(controller: controller, text: $text, textDidChange: textDidChange)
|
|
}
|
|
|
|
class WrappedTextView: UITextView {
|
|
private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
|
|
private let composeController: ComposeController
|
|
|
|
init(composeController: ComposeController) {
|
|
self.composeController = composeController
|
|
super.init(frame: .zero, textContainer: nil)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError()
|
|
}
|
|
|
|
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
|
if formattingActions.contains(action) {
|
|
return composeController.config.contentType != .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),
|
|
composeController.config.contentType != .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) {
|
|
composeController.paste(itemProviders: UIPasteboard.general.itemProviders)
|
|
} else {
|
|
super.paste(sender)
|
|
}
|
|
}
|
|
}
|
|
|
|
class Coordinator: NSObject, UITextViewDelegate, ComposeInput, TextViewCaretScrolling {
|
|
weak var textView: UITextView?
|
|
|
|
let controller: ComposeController
|
|
var text: Binding<String>
|
|
let textDidChange: (UITextView) -> Void
|
|
|
|
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
|
|
|
@Published var autocompleteState: AutocompleteState?
|
|
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { $autocompleteState }
|
|
var skipNextSelectionChangedAutocompleteUpdate = false
|
|
|
|
init(controller: ComposeController, text: Binding<String>, textDidChange: @escaping (UITextView) -> Void) {
|
|
self.controller = controller
|
|
self.text = text
|
|
self.textDidChange = textDidChange
|
|
|
|
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)
|
|
}
|
|
|
|
// MARK: UITextViewDelegate
|
|
|
|
func textViewDidChange(_ textView: UITextView) {
|
|
text.wrappedValue = textView.text
|
|
textDidChange(textView)
|
|
|
|
ensureCursorVisible(textView: textView)
|
|
}
|
|
|
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
|
controller.currentInput = self
|
|
updateAutocompleteState()
|
|
}
|
|
|
|
func textViewDidEndEditing(_ textView: UITextView) {
|
|
controller.currentInput = nil
|
|
updateAutocompleteState()
|
|
}
|
|
|
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
if skipNextSelectionChangedAutocompleteUpdate {
|
|
skipNextSelectionChangedAutocompleteUpdate = false
|
|
} else {
|
|
updateAutocompleteState()
|
|
}
|
|
}
|
|
|
|
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
|
var actions = suggestedActions
|
|
if controller.config.contentType != .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
|
|
return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { [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?.controller.shouldEmojiAutocompletionBeginExpanded = true
|
|
self?.beginAutocompletingEmoji()
|
|
}))
|
|
}
|
|
return UIMenu(children: actions)
|
|
}
|
|
|
|
// MARK: ComposeInput
|
|
|
|
var toolbarElements: [ToolbarElement] {
|
|
[.emojiPicker, .formattingButtons]
|
|
}
|
|
|
|
var textInputMode: UITextInputMode? {
|
|
textView?.textInputMode
|
|
}
|
|
|
|
func autocomplete(with string: String) {
|
|
textView?.autocomplete(with: string, permittedModes: .all, autocompleteState: &autocompleteState)
|
|
}
|
|
|
|
func applyFormat(_ format: StatusFormat) {
|
|
guard let textView,
|
|
textView.isFirstResponder,
|
|
let insertionResult = format.insertionResult(for: controller.config.contentType) 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.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.lowerBound)
|
|
let end = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.upperBound)
|
|
let selectedText = textView.text.utf16[start..<end]
|
|
textView.insertText(insertionResult.prefix + String(Substring(selectedText)) + insertionResult.suffix)
|
|
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: currentSelectedRange.length)
|
|
}
|
|
}
|
|
|
|
func beginAutocompletingEmoji() {
|
|
guard let 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 ? " " : "") + ":")
|
|
}
|
|
|
|
private func updateAutocompleteState() {
|
|
guard let textView else {
|
|
autocompleteState = nil
|
|
return
|
|
}
|
|
autocompleteState = textView.updateAutocompleteState(permittedModes: .all)
|
|
}
|
|
|
|
}
|
|
}
|