feat: implement inline emoji for text editor

This commit is contained in:
CMK 2021-03-16 14:19:12 +08:00
parent c8c296d1ba
commit fdcd1ffcd0
2 changed files with 67 additions and 14 deletions

View File

@ -9,6 +9,7 @@ import os.log
import UIKit import UIKit
import Combine import Combine
import TwitterTextEditor import TwitterTextEditor
import Kingfisher
final class ComposeViewController: UIViewController, NeedsDependency { final class ComposeViewController: UIViewController, NeedsDependency {
@ -18,6 +19,8 @@ final class ComposeViewController: UIViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
var viewModel: ComposeViewModel! var viewModel: ComposeViewModel!
private var suffixedAttachmentViews: [UIView] = []
let composeTootBarButtonItem: UIBarButtonItem = { let composeTootBarButtonItem: UIBarButtonItem = {
let button = RoundedEdgesButton(type: .custom) let button = RoundedEdgesButton(type: .custom)
button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) button.setTitle(L10n.Scene.Compose.composeAction, for: .normal)
@ -156,6 +159,20 @@ extension ComposeViewController {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: composeTootBarButtonItem) .assign(to: \.isEnabled, on: composeTootBarButtonItem)
.store(in: &disposeBag) .store(in: &disposeBag)
// bind custom emojis
viewModel.customEmojiViewModel
.compactMap { $0?.emojis }
.switchToLatest()
.sink(receiveValue: { [weak self] emojis in
guard let self = self else { return }
for emoji in emojis {
UITextChecker.learnWord(emoji.shortcode)
UITextChecker.learnWord(":" + emoji.shortcode + ":")
}
self.textEditorView()?.setNeedsUpdateTextAttributes()
})
.store(in: &disposeBag)
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
@ -164,15 +181,16 @@ extension ComposeViewController {
// Fix AutoLayout conflict issue // Fix AutoLayout conflict issue
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.markTextViewEditorBecomeFirstResponser() self.markTextEditorViewBecomeFirstResponser()
} }
} }
} }
extension ComposeViewController { extension ComposeViewController {
private func markTextViewEditorBecomeFirstResponser() {
guard let diffableDataSource = viewModel.diffableDataSource else { return } private func textEditorView() -> TextEditorView? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
let items = diffableDataSource.snapshot().itemIdentifiers let items = diffableDataSource.snapshot().itemIdentifiers
for item in items { for item in items {
switch item { switch item {
@ -181,12 +199,17 @@ extension ComposeViewController {
let cell = tableView.cellForRow(at: indexPath) as? ComposeTootContentTableViewCell else { let cell = tableView.cellForRow(at: indexPath) as? ComposeTootContentTableViewCell else {
continue continue
} }
cell.textEditorView.isEditing = true return cell.textEditorView
return
default: default:
continue continue
} }
} }
return nil
}
private func markTextEditorViewBecomeFirstResponser() {
textEditorView()?.isEditing = true
} }
private func showDismissConfirmAlertController() { private func showDismissConfirmAlertController() {
@ -233,8 +256,9 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
let stringRange = NSRange(location: 0, length: string.length) let stringRange = NSRange(location: 0, length: string.length)
let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s]+))") let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s]+))")
// not accept :$ to force user input space to make emoji take effect // accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect
let emojiMatches = string.matches(pattern: "(?:(^:|\\s:)([a-zA-Z0-9_]+):\\s)") // precondition :\B with following space
let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))")
// only accept http/https scheme // only accept http/https scheme
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
@ -243,6 +267,11 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
completion(nil) completion(nil)
return return
} }
let customEmojiViewModel = self.viewModel.customEmojiViewModel.value
for view in self.suffixedAttachmentViews {
view.removeFromSuperview()
}
self.suffixedAttachmentViews.removeAll()
// set normal apperance // set normal apperance
let attributedString = NSMutableAttributedString(attributedString: attributedString) let attributedString = NSMutableAttributedString(attributedString: attributedString)
@ -289,20 +318,44 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
} }
attributedString.addAttributes(attributes, range: match.range) attributedString.addAttributes(attributes, range: match.range)
} }
for match in emojiMatches {
if let name = string.substring(with: match, at: 2) { let emojis = customEmojiViewModel?.emojis.value ?? []
if !emojis.isEmpty {
for match in emojiMatches {
guard let name = string.substring(with: match, at: 2) else { continue }
guard let emoji = emojis.first(where: { $0.shortcode == name }) else { continue }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name)
// set emoji token invisiable (without upper bounce space) // set emoji token invisiable (without upper bounce space)
var attributes = [NSAttributedString.Key: Any]() var attributes = [NSAttributedString.Key: Any]()
attributes[.font] = UIFont.systemFont(ofSize: 0.01) attributes[.font] = UIFont.systemFont(ofSize: 0.01)
let rangeWithoutUpperBounceSpace = NSRange(location: match.range.location, length: match.range.length - 1) attributedString.addAttributes(attributes, range: match.range)
attributedString.addAttributes(attributes, range: rangeWithoutUpperBounceSpace)
// append emoji attachment // append emoji attachment
let imageViewSize = CGSize(width: 20, height: 20)
let imageView = UIImageView(frame: CGRect(origin: .zero, size: imageViewSize))
textEditorView.textContentView.addSubview(imageView)
self.suffixedAttachmentViews.append(imageView)
let processor = DownsamplingImageProcessor(size: imageViewSize)
imageView.kf.setImage(
with: URL(string: emoji.url),
placeholder: UIImage.placeholder(size: imageViewSize, color: .systemFill),
options: [
.processor(processor),
.scaleFactor(textEditorView.traitCollection.displayScale),
], completionHandler: nil
)
let layoutInTextContainer = { [weak textEditorView] (view: UIView, frame: CGRect) in
// `textEditorView` retains `textStorage`, which retains this block as a part of attributes.
guard let textEditorView = textEditorView else {
return
}
let insets = textEditorView.textContentInsets
view.frame = frame.offsetBy(dx: insets.left, dy: insets.top)
}
let attachment = TextAttributes.SuffixedAttachment( let attachment = TextAttributes.SuffixedAttachment(
size: CGSize(width: 20, height: 20), size: imageViewSize,
attachment: .image(UIImage(systemName: "circle")!) attachment: .view(view: imageView, layoutInTextContainer: layoutInTextContainer)
) )
let index = match.range.upperBound - 1 let index = match.range.upperBound - 1
attributedString.addAttribute( attributedString.addAttribute(

View File

@ -98,7 +98,7 @@ final class ComposeViewModel {
let domain = activeMastodonAuthenticationBox.domain let domain = activeMastodonAuthenticationBox.domain
// trigger dequeue to preload emojis // trigger dequeue to preload emojis
_ = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) self.customEmojiViewModel.value = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain)
} }
.store(in: &disposeBag) .store(in: &disposeBag)
} }