WIP rewritten main text view
This commit is contained in:
parent
54fadaa270
commit
17c67a3d5d
|
@ -12,7 +12,7 @@ import InstanceFeatures
|
||||||
public struct CharacterCounter {
|
public struct CharacterCounter {
|
||||||
|
|
||||||
private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||||
private static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
|
static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
|
||||||
|
|
||||||
public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int {
|
public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int {
|
||||||
let mentionsRemoved = removeMentions(in: text)
|
let mentionsRemoved = removeMentions(in: text)
|
||||||
|
|
|
@ -17,7 +17,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
||||||
Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
|
Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
|
||||||
Text("Happy π day!")
|
Text("Happy π day!")
|
||||||
} else if components.month == 4 && components.day == 1 {
|
} else if components.month == 4 && components.day == 1 {
|
||||||
Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center)
|
Text("April Fool’s!").rotationEffect(.radians(.pi), anchor: .center)
|
||||||
} else if components.month == 9 && components.day == 5 {
|
} else if components.month == 9 && components.day == 5 {
|
||||||
// https://weirder.earth/@noracodes/109276419847254552
|
// https://weirder.earth/@noracodes/109276419847254552
|
||||||
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
|
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
|
||||||
|
@ -31,7 +31,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
||||||
Text("Any questions?")
|
Text("Any questions?")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("What's on your mind?")
|
Text("What’s on your mind?")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,7 +41,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
// exists to provide access to the type alias since the @State property needs it to be explicit
|
// exists to provide access to the type alias since the @State property needs it to be explicit
|
||||||
private protocol PlaceholderViewProvider {
|
protocol PlaceholderViewProvider {
|
||||||
associatedtype PlaceholderView: View
|
associatedtype PlaceholderView: View
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
static func makePlaceholderView() -> PlaceholderView
|
static func makePlaceholderView() -> PlaceholderView
|
||||||
|
|
|
@ -12,6 +12,7 @@ struct ComposeView: View {
|
||||||
let mastodonController: any ComposeMastodonContext
|
let mastodonController: any ComposeMastodonContext
|
||||||
@State private var poster: PostService? = nil
|
@State private var poster: PostService? = nil
|
||||||
@FocusState private var focusedField: FocusableField?
|
@FocusState private var focusedField: FocusableField?
|
||||||
|
@EnvironmentObject private var controller: ComposeController
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
|
@ -55,7 +56,7 @@ struct ComposeView: View {
|
||||||
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
|
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarActions(draft: draft)
|
ToolbarActions(draft: draft, controller: controller)
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
ToolbarItem(placement: .bottomOrnament) {
|
ToolbarItem(placement: .bottomOrnament) {
|
||||||
toolbarView
|
toolbarView
|
||||||
|
@ -76,6 +77,8 @@ struct ComposeView: View {
|
||||||
NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
||||||
|
|
||||||
ContentWarningTextField(draft: draft, focusedField: $focusedField)
|
ContentWarningTextField(draft: draft, focusedField: $focusedField)
|
||||||
|
|
||||||
|
NewMainTextView(value: $draft.text, focusedField: $focusedField)
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
}
|
}
|
||||||
|
@ -113,7 +116,7 @@ public struct NavigationTitlePreferenceKey: PreferenceKey {
|
||||||
|
|
||||||
private struct ToolbarActions: ToolbarContent {
|
private struct ToolbarActions: ToolbarContent {
|
||||||
@ObservedObject var draft: Draft
|
@ObservedObject var draft: Draft
|
||||||
@EnvironmentObject private var controller: ComposeController
|
let controller: ComposeController
|
||||||
|
|
||||||
var body: some ToolbarContent {
|
var body: some ToolbarContent {
|
||||||
ToolbarItem(placement: .cancellationAction) { ToolbarCancelButton(draft: draft) }
|
ToolbarItem(placement: .cancellationAction) { ToolbarCancelButton(draft: draft) }
|
||||||
|
|
|
@ -0,0 +1,263 @@
|
||||||
|
//
|
||||||
|
// NewMainTextView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/11/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct NewMainTextView: View {
|
||||||
|
@Binding var value: String
|
||||||
|
@FocusState.Binding var focusedField: FocusableField?
|
||||||
|
@State private var becomeFirstResponder = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder)
|
||||||
|
.focused($focusedField, equals: .body)
|
||||||
|
.modifier(FocusedInputModifier())
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
if value.isEmpty {
|
||||||
|
PlaceholderView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
||||||
|
@Binding var value: String
|
||||||
|
@Binding var becomeFirstResponder: Bool
|
||||||
|
@Environment(\.composeInputBox) private var inputBox
|
||||||
|
@Environment(\.isEnabled) private var isEnabled
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
||||||
|
@Environment(\.composeUIConfig.useTwitterKeyboard) private var useTwitterKeyboard
|
||||||
|
// TODO: test textSelectionStartsAtBeginning
|
||||||
|
@Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> UITextView {
|
||||||
|
let view: UITextView
|
||||||
|
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
view = WrappedTextView(usingTextLayoutManager: true)
|
||||||
|
} else {
|
||||||
|
view = WrappedTextView()
|
||||||
|
}
|
||||||
|
view.delegate = context.coordinator
|
||||||
|
view.adjustsFontForContentSizeCategory = true
|
||||||
|
view.textContainer.lineBreakMode = .byWordWrapping
|
||||||
|
view.isScrollEnabled = false
|
||||||
|
view.typingAttributes = [
|
||||||
|
.foregroundColor: UIColor.label,
|
||||||
|
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
|
||||||
|
]
|
||||||
|
|
||||||
|
view.layer.cornerRadius = 5
|
||||||
|
view.layer.cornerCurve = .continuous
|
||||||
|
// view.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
// view.layer.shadowOpacity = 0.15
|
||||||
|
// view.layer.shadowOffset = .zero
|
||||||
|
// view.layer.shadowRadius = 1
|
||||||
|
|
||||||
|
if textSelectionStartsAtBeginning {
|
||||||
|
// Update the text immediately so that the selection isn't invalidated by the text changing.
|
||||||
|
context.coordinator.updateTextViewTextIfNecessary(value, textView: view)
|
||||||
|
view.selectedTextRange = view.textRange(from: view.beginningOfDocument, to: view.beginningOfDocument)
|
||||||
|
}
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: UIViewType, context: Context) {
|
||||||
|
context.coordinator.value = $value
|
||||||
|
context.coordinator.updateTextViewTextIfNecessary(value, textView: uiView)
|
||||||
|
|
||||||
|
uiView.isEditable = isEnabled
|
||||||
|
uiView.keyboardType = useTwitterKeyboard ? .twitter : .default
|
||||||
|
#if !os(visionOS)
|
||||||
|
uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Trying to set this with the @FocusState binding in onAppear results in the
|
||||||
|
// keyboard not appearing until after the sheet presentation animation completes :/
|
||||||
|
if becomeFirstResponder {
|
||||||
|
uiView.becomeFirstResponder()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
becomeFirstResponder = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> WrappedTextViewCoordinator {
|
||||||
|
let coordinator = WrappedTextViewCoordinator(value: $value)
|
||||||
|
// DispatchQueue.main.async {
|
||||||
|
// inputBox.wrappedValue = coordinator
|
||||||
|
// }
|
||||||
|
return coordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: fallback for this on iOS 15
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIViewType, context: Context) -> CGSize? {
|
||||||
|
let width = proposal.width ?? 10
|
||||||
|
let size = uiView.sizeThatFits(CGSize(width: width, height: 0))
|
||||||
|
return CGSize(width: width, height: max(150, size.height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class WrappedTextViewCoordinator: NSObject {
|
||||||
|
private static let attachment: NSTextAttachment = {
|
||||||
|
let font = UIFont.systemFont(ofSize: 20)
|
||||||
|
let size = /*1.4 * */font.capHeight
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
|
||||||
|
let image = renderer.image { ctx in
|
||||||
|
UIColor.systemRed.setFill()
|
||||||
|
ctx.fill(CGRect(x: 0, y: 0, width: size, height: size))
|
||||||
|
}
|
||||||
|
let attachment = NSTextAttachment(image: image)
|
||||||
|
attachment.bounds = CGRect(x: 0, y: -1, width: size + 2, height: size)
|
||||||
|
attachment.lineLayoutPadding = 1
|
||||||
|
return attachment
|
||||||
|
}()
|
||||||
|
|
||||||
|
var value: Binding<String>
|
||||||
|
|
||||||
|
init(value: Binding<String>) {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
private func plainTextFromAttributed(_ attributedText: NSAttributedString) -> String {
|
||||||
|
attributedText.string.replacingOccurrences(of: "\u{FFFC}", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attributedTextFromPlain(_ text: String) -> NSAttributedString {
|
||||||
|
let str = NSMutableAttributedString(string: text)
|
||||||
|
let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length))
|
||||||
|
for match in mentionMatches.reversed() {
|
||||||
|
let range: NSRange
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
|
||||||
|
range = NSRange(location: match.range.location, length: match.range.length + 1)
|
||||||
|
} else {
|
||||||
|
range = match.range
|
||||||
|
}
|
||||||
|
str.addAttributes([
|
||||||
|
.mention: true,
|
||||||
|
.foregroundColor: UIColor.tintColor,
|
||||||
|
], range: range)
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTextViewTextIfNecessary(_ text: String, textView: UITextView) {
|
||||||
|
if text != plainTextFromAttributed(textView.attributedText) {
|
||||||
|
textView.attributedText = attributedTextFromPlain(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAttributes(in textView: UITextView) {
|
||||||
|
let str = NSMutableAttributedString(attributedString: textView.attributedText!)
|
||||||
|
var changed = false
|
||||||
|
|
||||||
|
// remove existing mentions that aren't valid
|
||||||
|
str.enumerateAttribute(.mention, in: NSRange(location: 0, length: str.length), options: .reverse) { value, range, stop in
|
||||||
|
var substr = (str.string as NSString).substring(with: range)
|
||||||
|
let hasTextAttachment = substr.unicodeScalars.first == UnicodeScalar(NSTextAttachment.character)
|
||||||
|
if hasTextAttachment {
|
||||||
|
substr = String(substr.dropFirst())
|
||||||
|
}
|
||||||
|
if CharacterCounter.mention.numberOfMatches(in: substr, range: NSRange(location: 0, length: substr.utf16.count)) == 0 {
|
||||||
|
changed = true
|
||||||
|
str.removeAttribute(.mention, range: range)
|
||||||
|
str.removeAttribute(.foregroundColor, range: range)
|
||||||
|
if hasTextAttachment {
|
||||||
|
str.deleteCharacters(in: NSRange(location: range.location, length: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add mentions for those missing
|
||||||
|
let mentionMatches = CharacterCounter.mention.matches(in: str.string, range: NSRange(location: 0, length: str.length))
|
||||||
|
for match in mentionMatches.reversed() {
|
||||||
|
var attributeRange = NSRange()
|
||||||
|
let attribute = str.attribute(.mention, at: match.range.location, effectiveRange: &attributeRange)
|
||||||
|
// the attribute range should always be one greater than the match range, to account for the text attachment
|
||||||
|
if attribute == nil || attributeRange.length <= match.range.length {
|
||||||
|
changed = true
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
let newAttributeRange: NSRange
|
||||||
|
if attribute == nil {
|
||||||
|
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
|
||||||
|
newAttributeRange = NSRange(location: match.range.location, length: match.range.length + 1)
|
||||||
|
} else {
|
||||||
|
newAttributeRange = match.range
|
||||||
|
}
|
||||||
|
str.addAttributes([
|
||||||
|
.mention: true,
|
||||||
|
.foregroundColor: UIColor.tintColor,
|
||||||
|
], range: newAttributeRange)
|
||||||
|
} else {
|
||||||
|
str.addAttribute(.foregroundColor, value: UIColor.tintColor, range: match.range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
textView.attributedText = str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WrappedTextViewCoordinator: UITextViewDelegate {
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
if textView.text.isEmpty {
|
||||||
|
textView.typingAttributes = [
|
||||||
|
.foregroundColor: UIColor.label,
|
||||||
|
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
updateAttributes(in: textView)
|
||||||
|
}
|
||||||
|
|
||||||
|
let plain = plainTextFromAttributed(textView.attributedText)
|
||||||
|
if plain != value.wrappedValue {
|
||||||
|
value.wrappedValue = plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//extension WrappedTextViewCoordinator: ComposeInput {
|
||||||
|
//
|
||||||
|
//}
|
||||||
|
|
||||||
|
private final class WrappedTextView: UITextView {
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension NSAttributedString.Key {
|
||||||
|
static let mention = NSAttributedString.Key("Tusker.ComposeUI.mention")
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PlaceholderView: View {
|
||||||
|
@State private var placeholder: PlaceholderController.PlaceholderView = PlaceholderController.makePlaceholderView()
|
||||||
|
@ScaledMetric private var fontSize = 20
|
||||||
|
|
||||||
|
private var placeholderOffset: CGSize {
|
||||||
|
#if os(visionOS)
|
||||||
|
CGSize(width: 8, height: 8)
|
||||||
|
#else
|
||||||
|
CGSize(width: 4, height: 8)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
placeholder
|
||||||
|
.font(.system(size: fontSize))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.offset(placeholderOffset)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue