mirror of
git://jb55.com/damus
synced 2024-09-19 11:43:44 +00:00
compose: fix text wrapping issue when mentioning npub
Closes: https://github.com/damus-io/damus/issues/1211 Changelog-Fixed: Fix text composer wrapping issue when mentioning npub Signed-off-by: Daniel D’Aquino <daniel@daquino.me> Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
parent
aa4ecc2139
commit
01b8e43a6e
@ -14,6 +14,8 @@ enum NostrPostResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let POST_PLACEHOLDER = NSLocalizedString("Type your note here...", comment: "Text box prompt to ask user to type their note.")
|
let POST_PLACEHOLDER = NSLocalizedString("Type your note here...", comment: "Text box prompt to ask user to type their note.")
|
||||||
|
let GHOST_CARET_VIEW_ID = "GhostCaret"
|
||||||
|
let DEBUG_SHOW_GHOST_CARET_VIEW: Bool = false
|
||||||
|
|
||||||
class TagModel: ObservableObject {
|
class TagModel: ObservableObject {
|
||||||
var diff = 0
|
var diff = 0
|
||||||
@ -54,7 +56,8 @@ struct PostView: View {
|
|||||||
@State var filtered_pubkeys: Set<Pubkey> = []
|
@State var filtered_pubkeys: Set<Pubkey> = []
|
||||||
@State var focusWordAttributes: (String?, NSRange?) = (nil, nil)
|
@State var focusWordAttributes: (String?, NSRange?) = (nil, nil)
|
||||||
@State var newCursorIndex: Int?
|
@State var newCursorIndex: Int?
|
||||||
@State var postTextViewCanScroll: Bool = true
|
@State var caretRect: CGRect = CGRectNull
|
||||||
|
@State var textHeight: CGFloat? = nil
|
||||||
|
|
||||||
@State var mediaToUpload: MediaUpload? = nil
|
@State var mediaToUpload: MediaUpload? = nil
|
||||||
|
|
||||||
@ -104,6 +107,16 @@ struct PostView: View {
|
|||||||
return is_post_empty || uploading_disabled
|
return is_post_empty || uploading_disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns a valid height for the text box, even when textHeight is not a number
|
||||||
|
func get_valid_text_height() -> CGFloat {
|
||||||
|
if let textHeight, textHeight.isFinite, textHeight > 0 {
|
||||||
|
return textHeight
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var ImageButton: some View {
|
var ImageButton: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
attach_media = true
|
attach_media = true
|
||||||
@ -201,11 +214,18 @@ struct PostView: View {
|
|||||||
|
|
||||||
var TextEntry: some View {
|
var TextEntry: some View {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
TextViewWrapper(attributedText: $post, postTextViewCanScroll: $postTextViewCanScroll, cursorIndex: newCursorIndex, getFocusWordForMention: { word, range in
|
TextViewWrapper(attributedText: $post, textHeight: $textHeight, cursorIndex: newCursorIndex, getFocusWordForMention: { word, range in
|
||||||
focusWordAttributes = (word, range)
|
focusWordAttributes = (word, range)
|
||||||
self.newCursorIndex = nil
|
self.newCursorIndex = nil
|
||||||
}, updateCursorPosition: { newCursorIndex in
|
}, updateCursorPosition: { newCursorIndex in
|
||||||
self.newCursorIndex = newCursorIndex
|
self.newCursorIndex = newCursorIndex
|
||||||
|
}, onCaretRectChange: { uiView in
|
||||||
|
// When the caret position changes, we change the `caretRect` in our state, so that our ghost caret will follow our caret
|
||||||
|
if let selectedStartRange = uiView.selectedTextRange?.start {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
caretRect = uiView.caretRect(for: selectedStartRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.environmentObject(tagModel)
|
.environmentObject(tagModel)
|
||||||
.focused($focus)
|
.focused($focus)
|
||||||
@ -213,6 +233,8 @@ struct PostView: View {
|
|||||||
.onChange(of: post) { p in
|
.onChange(of: post) { p in
|
||||||
post_changed(post: p, media: uploadedMedias)
|
post_changed(post: p, media: uploadedMedias)
|
||||||
}
|
}
|
||||||
|
// Set a height based on the text content height, if it is available and valid
|
||||||
|
.frame(height: get_valid_text_height())
|
||||||
|
|
||||||
if post.string.isEmpty {
|
if post.string.isEmpty {
|
||||||
Text(POST_PLACEHOLDER)
|
Text(POST_PLACEHOLDER)
|
||||||
@ -292,13 +314,16 @@ struct PostView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Editor(deviceSize: GeometryProxy) -> some View {
|
func Editor(deviceSize: GeometryProxy) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
if(caretRect != CGRectNull) {
|
||||||
|
GhostCaret
|
||||||
|
}
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
ProfilePicView(pubkey: damus_state.pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
ProfilePicView(pubkey: damus_state.pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||||
|
|
||||||
TextEntry
|
TextEntry
|
||||||
}
|
}
|
||||||
.frame(height: deviceSize.size.height * multiply_factor)
|
|
||||||
.id("post")
|
.id("post")
|
||||||
|
|
||||||
PVImageCarouselView(media: $uploadedMedias, deviceWidth: deviceSize.size.width)
|
PVImageCarouselView(media: $uploadedMedias, deviceWidth: deviceSize.size.width)
|
||||||
@ -312,6 +337,26 @@ struct PostView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The GhostCaret is a vertical projection of the editor's caret that should sit beside the editor.
|
||||||
|
// The purpose of this view is create a reference point that we can scroll our ScrollView into
|
||||||
|
// This is necessary as a bridge to communicate between:
|
||||||
|
// - The UIKit-based UITextView (which has the caret position)
|
||||||
|
// - and the SwiftUI-based ScrollView/ScrollReader (where scrolling commands can only be done via the SwiftUI "ID" parameter
|
||||||
|
var GhostCaret: some View {
|
||||||
|
Rectangle()
|
||||||
|
.foregroundStyle(DEBUG_SHOW_GHOST_CARET_VIEW ? .cyan : .init(red: 0, green: 0, blue: 0, opacity: 0))
|
||||||
|
.frame(
|
||||||
|
width: DEBUG_SHOW_GHOST_CARET_VIEW ? caretRect.width : 0,
|
||||||
|
height: caretRect.height)
|
||||||
|
// Use padding to vertically align our ghost caret with our actual text caret.
|
||||||
|
// Note: Programmatic scrolling cannot be done with the `.position` modifier.
|
||||||
|
// Experiments revealed that the scroller ignores the position modifier.
|
||||||
|
.padding(.top, caretRect.origin.y)
|
||||||
|
.id(GHOST_CARET_VIEW_ID)
|
||||||
|
.disabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
func fill_target_content(target: PostTarget) {
|
func fill_target_content(target: PostTarget) {
|
||||||
self.post = initialString()
|
self.post = initialString()
|
||||||
@ -332,26 +377,36 @@ struct PostView: View {
|
|||||||
GeometryReader { (deviceSize: GeometryProxy) in
|
GeometryReader { (deviceSize: GeometryProxy) in
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
let searching = get_searching_string(focusWordAttributes.0)
|
let searching = get_searching_string(focusWordAttributes.0)
|
||||||
|
let searchingIsNil = searching == nil
|
||||||
|
|
||||||
TopBar
|
TopBar
|
||||||
|
|
||||||
ScrollViewReader { scroller in
|
ScrollViewReader { scroller in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
if case .replying_to(let replying_to) = self.action {
|
if case .replying_to(let replying_to) = self.action {
|
||||||
ReplyView(replying_to: replying_to, damus: damus_state, original_pubkeys: pubkeys, filtered_pubkeys: $filtered_pubkeys)
|
ReplyView(replying_to: replying_to, damus: damus_state, original_pubkeys: pubkeys, filtered_pubkeys: $filtered_pubkeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
Editor(deviceSize: deviceSize)
|
Editor(deviceSize: deviceSize)
|
||||||
}
|
}
|
||||||
.frame(maxHeight: searching == nil ? .infinity : 70)
|
}
|
||||||
|
.frame(maxHeight: searching == nil ? deviceSize.size.height : 70)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
scroll_to_event(scroller: scroller, id: "post", delay: 1.0, animate: true, anchor: .top)
|
scroll_to_event(scroller: scroller, id: "post", delay: 1.0, animate: true, anchor: .top)
|
||||||
}
|
}
|
||||||
|
// Note: The scroll commands below are specific because there seems to be quirk with ScrollReader where sending it to the exact same position twice resets its scroll position.
|
||||||
|
.onChange(of: caretRect.origin.y, perform: { newValue in
|
||||||
|
scroller.scrollTo(GHOST_CARET_VIEW_ID)
|
||||||
|
})
|
||||||
|
.onChange(of: searchingIsNil, perform: { newValue in
|
||||||
|
scroller.scrollTo(GHOST_CARET_VIEW_ID)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// This if-block observes @ for tagging
|
// This if-block observes @ for tagging
|
||||||
if let searching {
|
if let searching {
|
||||||
UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, postTextViewCanScroll: $postTextViewCanScroll, post: $post)
|
UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, post: $post)
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
.environmentObject(tagModel)
|
.environmentObject(tagModel)
|
||||||
} else {
|
} else {
|
||||||
|
@ -21,7 +21,6 @@ struct UserSearch: View {
|
|||||||
let search: String
|
let search: String
|
||||||
@Binding var focusWordAttributes: (String?, NSRange?)
|
@Binding var focusWordAttributes: (String?, NSRange?)
|
||||||
@Binding var newCursorIndex: Int?
|
@Binding var newCursorIndex: Int?
|
||||||
@Binding var postTextViewCanScroll: Bool
|
|
||||||
|
|
||||||
@Binding var post: NSMutableAttributedString
|
@Binding var post: NSMutableAttributedString
|
||||||
@EnvironmentObject var tagModel: TagModel
|
@EnvironmentObject var tagModel: TagModel
|
||||||
@ -70,12 +69,6 @@ struct UserSearch: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear() {
|
|
||||||
postTextViewCanScroll = false
|
|
||||||
}
|
|
||||||
.onDisappear() {
|
|
||||||
postTextViewCanScroll = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -85,10 +78,9 @@ struct UserSearch_Previews: PreviewProvider {
|
|||||||
@State static var post: NSMutableAttributedString = NSMutableAttributedString(string: "some @jb55")
|
@State static var post: NSMutableAttributedString = NSMutableAttributedString(string: "some @jb55")
|
||||||
@State static var word: (String?, NSRange?) = (nil, nil)
|
@State static var word: (String?, NSRange?) = (nil, nil)
|
||||||
@State static var newCursorIndex: Int?
|
@State static var newCursorIndex: Int?
|
||||||
@State static var postTextViewCanScroll: Bool = false
|
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
UserSearch(damus_state: test_damus_state(), search: search, focusWordAttributes: $word, newCursorIndex: $newCursorIndex, postTextViewCanScroll: $postTextViewCanScroll, post: $post)
|
UserSearch(damus_state: test_damus_state(), search: search, focusWordAttributes: $word, newCursorIndex: $newCursorIndex, post: $post)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,20 +7,32 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// Defines how much extra bottom spacing will be applied after the text.
|
||||||
|
// This will avoid jitters when applying new lines, by ensuring it has enough space until the height is updated on the next view update cycle
|
||||||
|
let TEXT_BOX_BOTTOM_MARGIN_OFFSET: CGFloat = 30.0
|
||||||
|
|
||||||
struct TextViewWrapper: UIViewRepresentable {
|
struct TextViewWrapper: UIViewRepresentable {
|
||||||
@Binding var attributedText: NSMutableAttributedString
|
@Binding var attributedText: NSMutableAttributedString
|
||||||
@Binding var postTextViewCanScroll: Bool
|
|
||||||
@EnvironmentObject var tagModel: TagModel
|
@EnvironmentObject var tagModel: TagModel
|
||||||
|
@Binding var textHeight: CGFloat?
|
||||||
|
|
||||||
let cursorIndex: Int?
|
let cursorIndex: Int?
|
||||||
var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil
|
var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil
|
||||||
let updateCursorPosition: ((Int) -> Void)
|
let updateCursorPosition: ((Int) -> Void)
|
||||||
|
let onCaretRectChange: ((UITextView) -> Void)
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
func makeUIView(context: Context) -> UITextView {
|
||||||
let textView = UITextView()
|
let textView = UITextView()
|
||||||
textView.delegate = context.coordinator
|
textView.delegate = context.coordinator
|
||||||
textView.isScrollEnabled = postTextViewCanScroll
|
|
||||||
|
// Scroll has to be enabled. When this is disabled, the text input will overflow horizontally, even when its frame's width is limited.
|
||||||
|
textView.isScrollEnabled = true
|
||||||
|
// However, a scrolling text box inside of its parent scrollview does not provide a very good experience. We should have the textbox expand vertically
|
||||||
|
// To simulate that the text box can expand vertically, we will listen to text changes and dynamically change the text box height in response.
|
||||||
|
// Add an observer so that we can adapt the height of the text input whenever the text changes.
|
||||||
|
textView.addObserver(context.coordinator, forKeyPath: "contentSize", options: .new, context: nil)
|
||||||
textView.showsVerticalScrollIndicator = false
|
textView.showsVerticalScrollIndicator = false
|
||||||
|
|
||||||
TextViewWrapper.setTextProperties(textView)
|
TextViewWrapper.setTextProperties(textView)
|
||||||
return textView
|
return textView
|
||||||
}
|
}
|
||||||
@ -34,7 +46,6 @@ struct TextViewWrapper: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: UITextView, context: Context) {
|
func updateUIView(_ uiView: UITextView, context: Context) {
|
||||||
uiView.isScrollEnabled = postTextViewCanScroll
|
|
||||||
uiView.attributedText = attributedText
|
uiView.attributedText = attributedText
|
||||||
|
|
||||||
TextViewWrapper.setTextProperties(uiView)
|
TextViewWrapper.setTextProperties(uiView)
|
||||||
@ -53,18 +64,27 @@ struct TextViewWrapper: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention, updateCursorPosition: updateCursorPosition)
|
Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention, updateCursorPosition: updateCursorPosition, onCaretRectChange: onCaretRectChange, textHeight: $textHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
class Coordinator: NSObject, UITextViewDelegate {
|
class Coordinator: NSObject, UITextViewDelegate {
|
||||||
@Binding var attributedText: NSMutableAttributedString
|
@Binding var attributedText: NSMutableAttributedString
|
||||||
var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil
|
var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil
|
||||||
let updateCursorPosition: ((Int) -> Void)
|
let updateCursorPosition: ((Int) -> Void)
|
||||||
|
let onCaretRectChange: ((UITextView) -> Void)
|
||||||
|
@Binding var textHeight: CGFloat?
|
||||||
|
|
||||||
init(attributedText: Binding<NSMutableAttributedString>, getFocusWordForMention: ((String?, NSRange?) -> Void)?, updateCursorPosition: @escaping ((Int) -> Void)) {
|
init(attributedText: Binding<NSMutableAttributedString>,
|
||||||
|
getFocusWordForMention: ((String?, NSRange?) -> Void)?,
|
||||||
|
updateCursorPosition: @escaping ((Int) -> Void),
|
||||||
|
onCaretRectChange: @escaping ((UITextView) -> Void),
|
||||||
|
textHeight: Binding<CGFloat?>
|
||||||
|
) {
|
||||||
_attributedText = attributedText
|
_attributedText = attributedText
|
||||||
self.getFocusWordForMention = getFocusWordForMention
|
self.getFocusWordForMention = getFocusWordForMention
|
||||||
self.updateCursorPosition = updateCursorPosition
|
self.updateCursorPosition = updateCursorPosition
|
||||||
|
self.onCaretRectChange = onCaretRectChange
|
||||||
|
_textHeight = textHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
@ -72,6 +92,11 @@ struct TextViewWrapper: UIViewRepresentable {
|
|||||||
processFocusedWordForMention(textView: textView)
|
processFocusedWordForMention(textView: textView)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||||
|
textView.scrollRangeToVisible(textView.selectedRange)
|
||||||
|
onCaretRectChange(textView)
|
||||||
|
}
|
||||||
|
|
||||||
private func processFocusedWordForMention(textView: UITextView) {
|
private func processFocusedWordForMention(textView: UITextView) {
|
||||||
var val: (String?, NSRange?) = (nil, nil)
|
var val: (String?, NSRange?) = (nil, nil)
|
||||||
|
|
||||||
@ -158,6 +183,20 @@ struct TextViewWrapper: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||||
|
if keyPath == "contentSize", let textView = object as? UITextView {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// Update text view height when text content size changes to fit all text content
|
||||||
|
// This is necessary to avoid having a scrolling text box combined with its parent scrolling view
|
||||||
|
self.updateTextViewHeight(textView: textView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTextViewHeight(textView: UITextView) {
|
||||||
|
self.textHeight = textView.contentSize.height + TEXT_BOX_BOTTOM_MARGIN_OFFSET
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user