forked from zelo72/mastodon-ios
feat: implement inline emoji for text editor
This commit is contained in:
parent
c8c296d1ba
commit
fdcd1ffcd0
|
@ -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(
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue