feat: [WIP] add auto completion view for compose highlight
This commit is contained in:
parent
761d094832
commit
c2c38c9307
|
@ -206,8 +206,6 @@
|
|||
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; };
|
||||
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; };
|
||||
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; };
|
||||
DB35B0B32643D821006AC73B /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DB35B0B22643D821006AC73B /* TwitterTextEditor */; };
|
||||
DB35B0B42643D821006AC73B /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DB35B0B22643D821006AC73B /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; };
|
||||
DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; };
|
||||
DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; };
|
||||
|
@ -311,6 +309,11 @@
|
|||
DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F7C26358ED4008423CD /* SettingsSection.swift */; };
|
||||
DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F8326358EEC008423CD /* SettingsItem.swift */; };
|
||||
DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F9626367249008423CD /* SettingsViewController.swift */; };
|
||||
DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */; };
|
||||
DB6F5E32264E7410009108F4 /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DB6F5E31264E7410009108F4 /* TwitterTextEditor */; };
|
||||
DB6F5E33264E7410009108F4 /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DB6F5E31264E7410009108F4 /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
DB6F5E35264E78E7009108F4 /* AutoCompletionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E34264E78E7009108F4 /* AutoCompletionViewController.swift */; };
|
||||
DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */; };
|
||||
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; };
|
||||
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; };
|
||||
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; };
|
||||
|
@ -527,7 +530,7 @@
|
|||
files = (
|
||||
DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */,
|
||||
DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */,
|
||||
DB35B0B42643D821006AC73B /* TwitterTextEditor in Embed Frameworks */,
|
||||
DB6F5E33264E7410009108F4 /* TwitterTextEditor in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -853,6 +856,9 @@
|
|||
DB6D9F7C26358ED4008423CD /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = "<group>"; };
|
||||
DB6D9F8326358EEC008423CD /* SettingsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsItem.swift; sourceTree = "<group>"; };
|
||||
DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
|
||||
DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = "<group>"; };
|
||||
DB6F5E34264E78E7009108F4 /* AutoCompletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompletionViewController.swift; sourceTree = "<group>"; };
|
||||
DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTopChevronView.swift; sourceTree = "<group>"; };
|
||||
DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = "<group>"; };
|
||||
DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = "<group>"; };
|
||||
DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = "<group>"; };
|
||||
|
@ -993,7 +999,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
DB35B0B32643D821006AC73B /* TwitterTextEditor in Frameworks */,
|
||||
DB6F5E32264E7410009108F4 /* TwitterTextEditor in Frameworks */,
|
||||
DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */,
|
||||
DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */,
|
||||
2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */,
|
||||
|
@ -1874,6 +1880,15 @@
|
|||
path = MastodonSDK;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB6F5E36264E78EA009108F4 /* AutoCompletion */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB6F5E34264E78E7009108F4 /* AutoCompletionViewController.swift */,
|
||||
DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */,
|
||||
);
|
||||
path = AutoCompletion;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB72602125E36A2500235243 /* ServerRules */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1894,6 +1909,7 @@
|
|||
DB789A1025F9F29B0071ACA0 /* Compose */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB6F5E36264E78EA009108F4 /* AutoCompletion */,
|
||||
DB55D32225FB4D320002F825 /* View */,
|
||||
DB789A2125F9F76D0071ACA0 /* CollectionViewCell */,
|
||||
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */,
|
||||
|
@ -2199,6 +2215,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */,
|
||||
DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */,
|
||||
DB35FC2E26130172006193C9 /* MastodonField.swift */,
|
||||
DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */,
|
||||
);
|
||||
|
@ -2400,7 +2417,7 @@
|
|||
2D939AC725EE14620076FA61 /* CropViewController */,
|
||||
DB9A487D2603456B008B817C /* UITextView+Placeholder */,
|
||||
DBB525072611EAC0002F1F29 /* Tabman */,
|
||||
DB35B0B22643D821006AC73B /* TwitterTextEditor */,
|
||||
DB6F5E31264E7410009108F4 /* TwitterTextEditor */,
|
||||
);
|
||||
productName = Mastodon;
|
||||
productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */;
|
||||
|
@ -2589,7 +2606,7 @@
|
|||
DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */,
|
||||
DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */,
|
||||
DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */,
|
||||
DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */,
|
||||
DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */,
|
||||
);
|
||||
productRefGroup = DB427DD325BAA00100D1B89D /* Products */;
|
||||
projectDirPath = "";
|
||||
|
@ -3035,6 +3052,7 @@
|
|||
2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */,
|
||||
DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */,
|
||||
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
|
||||
DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */,
|
||||
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
|
||||
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
|
||||
DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */,
|
||||
|
@ -3068,6 +3086,7 @@
|
|||
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
|
||||
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
|
||||
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
||||
DB6F5E35264E78E7009108F4 /* AutoCompletionViewController.swift in Sources */,
|
||||
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
|
||||
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
|
||||
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
|
||||
|
@ -3160,6 +3179,7 @@
|
|||
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */,
|
||||
DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */,
|
||||
DB6D9F6F2635807F008423CD /* Setting.swift in Sources */,
|
||||
DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */,
|
||||
DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */,
|
||||
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
|
||||
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,
|
||||
|
@ -3938,14 +3958,6 @@
|
|||
minimumVersion = 0.1.1;
|
||||
};
|
||||
};
|
||||
DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/twitter/TwitterTextEditor";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.1.0;
|
||||
};
|
||||
};
|
||||
DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Alamofire/AlamofireImage.git";
|
||||
|
@ -3970,6 +3982,14 @@
|
|||
minimumVersion = 4.2.2;
|
||||
};
|
||||
};
|
||||
DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/MainasuK/TwitterTextEditor.git";
|
||||
requirement = {
|
||||
branch = "feature/expose-layout";
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder";
|
||||
|
@ -4031,11 +4051,6 @@
|
|||
package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */;
|
||||
productName = CommonOSLog;
|
||||
};
|
||||
DB35B0B22643D821006AC73B /* TwitterTextEditor */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */;
|
||||
productName = TwitterTextEditor;
|
||||
};
|
||||
DB3D0FF225BAA61700EAA174 /* AlamofireImage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */;
|
||||
|
@ -4056,6 +4071,11 @@
|
|||
package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */;
|
||||
productName = AlamofireImage;
|
||||
};
|
||||
DB6F5E31264E7410009108F4 /* TwitterTextEditor */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */;
|
||||
productName = TwitterTextEditor;
|
||||
};
|
||||
DB9A487D2603456B008B817C /* UITextView+Placeholder */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */;
|
||||
|
|
|
@ -138,11 +138,11 @@
|
|||
},
|
||||
{
|
||||
"package": "TwitterTextEditor",
|
||||
"repositoryURL": "https://github.com/twitter/TwitterTextEditor",
|
||||
"repositoryURL": "https://github.com/MainasuK/TwitterTextEditor.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "dfe0edc3bcb6703ee2fd0e627f95e726b63e732a",
|
||||
"version": "1.1.0"
|
||||
"branch": "feature/expose-layout",
|
||||
"revision": "c208329b23dcb3c8c7192de34776440d625a26a4",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -37,6 +37,7 @@ extension ComposeStatusSection {
|
|||
repliedToCellFrameSubscriber: CurrentValueSubject<CGRect, Never>,
|
||||
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
|
||||
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
|
||||
textEditorViewChangeObserver: TextEditorViewChangeObserver,
|
||||
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
|
||||
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
|
||||
composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
|
||||
|
@ -45,6 +46,7 @@ extension ComposeStatusSection {
|
|||
UICollectionViewDiffableDataSource(collectionView: collectionView) { [
|
||||
weak customEmojiPickerInputViewModel,
|
||||
weak textEditorViewTextAttributesDelegate,
|
||||
weak textEditorViewChangeObserver,
|
||||
weak composeStatusAttachmentTableViewCellDelegate,
|
||||
weak composeStatusPollOptionCollectionViewCellDelegate,
|
||||
weak composeStatusNewPollOptionCollectionViewCellDelegate,
|
||||
|
@ -92,6 +94,7 @@ extension ComposeStatusSection {
|
|||
}
|
||||
ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute)
|
||||
cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate
|
||||
cell.textEditorViewChangeObserver = textEditorViewChangeObserver // relay
|
||||
cell.composeContent
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
|
|
@ -152,41 +152,4 @@ extension ActiveLabel {
|
|||
return elements
|
||||
}
|
||||
|
||||
// public override func accessibilityElementCount() -> Int {
|
||||
// return 1 + activeEntities.count
|
||||
// }
|
||||
//
|
||||
// public override func accessibilityElement(at index: Int) -> Any? {
|
||||
// if index == 0 {
|
||||
// let element = ActiveLabelAccessibilityElement(accessibilityContainer: self)
|
||||
// element.accessibilityTraits = .staticText
|
||||
// element.accessibilityLabel = accessibilityLabel
|
||||
// element.accessibilityFrame = superview!.convert(frame, to: nil)
|
||||
// element.index = index
|
||||
// return element
|
||||
// }
|
||||
//
|
||||
// let index = index - 1
|
||||
// guard index < activeEntities.count else { return nil }
|
||||
// let eneity = activeEntities[index]
|
||||
// guard let element = eneity.accessibilityElement(in: self) else { return nil }
|
||||
//
|
||||
// var glyphRange = NSRange()
|
||||
// layoutManager.characterRange(forGlyphRange: eneity.range, actualGlyphRange: &glyphRange)
|
||||
// let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||
// element.accessibilityFrame = self.convert(rect, to: nil)
|
||||
// element.accessibilityContainer = self
|
||||
//
|
||||
// return element
|
||||
// }
|
||||
//
|
||||
// public override func index(ofAccessibilityElement element: Any) -> Int {
|
||||
// guard let element = element as? ActiveLabelAccessibilityElement,
|
||||
// let index = element.index else {
|
||||
// return NSNotFound
|
||||
// }
|
||||
//
|
||||
// return index
|
||||
// }
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// MastodonRegex.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-5-14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MastodonRegex {
|
||||
/// mention, hashtag.
|
||||
/// @...
|
||||
/// #...
|
||||
static let highlightPattern = "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))"
|
||||
/// emoji
|
||||
/// :shortcode:
|
||||
/// accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect
|
||||
/// precondition :\B with following space
|
||||
static let emojiPattern = "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))"
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
//
|
||||
// AutoCompleteTopChevronView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-5-14.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class AutoCompleteTopChevronView: UIView {
|
||||
|
||||
static let chevronSize = CGSize(width: 20, height: 12)
|
||||
|
||||
var chevronMinX: CGFloat = 0
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
func _init() {
|
||||
backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
let bezierPath = UIBezierPath()
|
||||
let bottomY = rect.height
|
||||
let topY = 0
|
||||
let count = Int(ceil(rect.width / CGFloat(SawToothView.widthUint)))
|
||||
bezierPath.move(to: CGPoint(x: 0, y: bottomY))
|
||||
for n in 0 ..< count {
|
||||
bezierPath.addLine(to: CGPoint(x: CGFloat((Double(n) + 0.5) * Double(SawToothView.widthUint)), y: CGFloat(topY)))
|
||||
bezierPath.addLine(to: CGPoint(x: CGFloat((Double(n) + 1) * Double(SawToothView.widthUint)), y: CGFloat(bottomY)))
|
||||
}
|
||||
bezierPath.addLine(to: CGPoint(x: 0, y: bottomY))
|
||||
bezierPath.close()
|
||||
Asset.Colors.Background.systemBackground.color.setFill()
|
||||
bezierPath.fill()
|
||||
bezierPath.lineWidth = 0
|
||||
bezierPath.stroke()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// AutoCompletionViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-5-14.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class AutoCompletionViewController: UIViewController {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
}
|
||||
|
||||
extension AutoCompletionViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = .red.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
}
|
|
@ -18,6 +18,7 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell {
|
|||
|
||||
let statusContentWarningEditorView = StatusContentWarningEditorView()
|
||||
|
||||
let textEditorViewContainerView = UIView()
|
||||
let textEditorView: TextEditorView = {
|
||||
let textEditorView = TextEditorView()
|
||||
textEditorView.font = .preferredFont(forTextStyle: .body)
|
||||
|
@ -28,6 +29,9 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell {
|
|||
return textEditorView
|
||||
}()
|
||||
|
||||
// input
|
||||
weak var textEditorViewChangeObserver: TextEditorViewChangeObserver?
|
||||
|
||||
// output
|
||||
let composeContent = PassthroughSubject<String, Never>()
|
||||
let contentWarningContent = PassthroughSubject<String, Never>()
|
||||
|
@ -75,13 +79,23 @@ extension ComposeStatusContentCollectionViewCell {
|
|||
statusView.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||
statusView.setContentCompressionResistancePriority(.required - 1, for: .vertical)
|
||||
|
||||
textEditorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(textEditorView)
|
||||
textEditorViewContainerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(textEditorViewContainerView)
|
||||
NSLayoutConstraint.activate([
|
||||
textEditorView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10),
|
||||
textEditorView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
|
||||
textEditorView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: textEditorView.bottomAnchor, constant: 10),
|
||||
textEditorViewContainerView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10),
|
||||
textEditorViewContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
textEditorViewContainerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor, constant: 10),
|
||||
])
|
||||
textEditorViewContainerView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
textEditorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
textEditorViewContainerView.addSubview(textEditorView)
|
||||
NSLayoutConstraint.activate([
|
||||
textEditorView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor),
|
||||
textEditorView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.leadingAnchor),
|
||||
textEditorView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.trailingAnchor),
|
||||
textEditorView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor),
|
||||
textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
|
||||
])
|
||||
textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical)
|
||||
|
@ -98,6 +112,10 @@ extension ComposeStatusContentCollectionViewCell {
|
|||
// MARK: - TextEditorViewChangeObserver
|
||||
extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver {
|
||||
func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
|
||||
defer {
|
||||
textEditorViewChangeObserver?.textEditorView(textEditorView, didChangeWithChangeResult: changeResult)
|
||||
}
|
||||
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text)
|
||||
guard changeResult.isTextChanged else { return }
|
||||
composeContent.send(textEditorView.text)
|
||||
|
|
|
@ -15,6 +15,8 @@ import TwitterTextEditor
|
|||
|
||||
final class ComposeViewController: UIViewController, NeedsDependency {
|
||||
|
||||
static let minAutoCompletionVisibleHeight: CGFloat = 100
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
|
@ -93,6 +95,12 @@ final class ComposeViewController: UIViewController, NeedsDependency {
|
|||
return documentPickerController
|
||||
}()
|
||||
|
||||
private(set) lazy var autoCompletionViewController: AutoCompletionViewController = {
|
||||
let viewController = AutoCompletionViewController()
|
||||
viewController.context = context
|
||||
return viewController
|
||||
}()
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
@ -166,6 +174,7 @@ extension ComposeViewController {
|
|||
dependency: self,
|
||||
customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel,
|
||||
textEditorViewTextAttributesDelegate: self,
|
||||
textEditorViewChangeObserver: self,
|
||||
composeStatusAttachmentTableViewCellDelegate: self,
|
||||
composeStatusPollOptionCollectionViewCellDelegate: self,
|
||||
composeStatusNewPollOptionCollectionViewCellDelegate: self,
|
||||
|
@ -181,26 +190,27 @@ extension ComposeViewController {
|
|||
dependency: self
|
||||
)
|
||||
|
||||
// respond scrollView overlap change
|
||||
//view.layoutIfNeeded()
|
||||
// update layout when keyboard show/dismiss
|
||||
Publishers.CombineLatest4(
|
||||
KeyboardResponderService.shared.isShow.eraseToAnyPublisher(),
|
||||
KeyboardResponderService.shared.state.eraseToAnyPublisher(),
|
||||
KeyboardResponderService.shared.endFrame.eraseToAnyPublisher(),
|
||||
viewModel.isCustomEmojiComposing.eraseToAnyPublisher()
|
||||
let keyboardEventPublishers = Publishers.CombineLatest3(
|
||||
KeyboardResponderService.shared.isShow,
|
||||
KeyboardResponderService.shared.state,
|
||||
KeyboardResponderService.shared.endFrame
|
||||
)
|
||||
.sink(receiveValue: { [weak self] isShow, state, endFrame, isCustomEmojiComposing in
|
||||
Publishers.CombineLatest3(
|
||||
keyboardEventPublishers,
|
||||
viewModel.isCustomEmojiComposing,
|
||||
viewModel.autoCompletion
|
||||
)
|
||||
.sink(receiveValue: { [weak self] keyboardEvents, isCustomEmojiComposing, autoCompletion in
|
||||
guard let self = self else { return }
|
||||
|
||||
let (isShow, state, endFrame) = keyboardEvents
|
||||
let extraMargin: CGFloat = {
|
||||
if self.view.safeAreaInsets.bottom == .zero {
|
||||
// needs extra margin for zero inset device to workaround UIKit issue
|
||||
return self.composeToolbarView.frame.height
|
||||
} else {
|
||||
// default some magic 16 extra margin
|
||||
return 16
|
||||
var margin = self.composeToolbarView.frame.height
|
||||
if autoCompletion != nil {
|
||||
margin += ComposeViewController.minAutoCompletionVisibleHeight
|
||||
}
|
||||
return margin
|
||||
}()
|
||||
|
||||
// update keyboard background color
|
||||
|
@ -221,28 +231,45 @@ extension ComposeViewController {
|
|||
self.systemKeyboardHeight = endFrame.height
|
||||
|
||||
let contentFrame = self.view.convert(self.collectionView.frame, to: nil)
|
||||
let padding = contentFrame.maxY - endFrame.minY
|
||||
let padding = contentFrame.maxY + extraMargin - endFrame.minY
|
||||
guard padding > 0 else {
|
||||
self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin
|
||||
self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
self.updateKeyboardBackground(isKeyboardDisplay: false)
|
||||
return
|
||||
}
|
||||
|
||||
self.collectionView.contentInset.bottom = padding + extraMargin
|
||||
self.collectionView.verticalScrollIndicatorInsets.bottom = padding + extraMargin
|
||||
self.collectionView.contentInset.bottom = padding
|
||||
self.collectionView.verticalScrollIndicatorInsets.bottom = padding
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
self.composeToolbarViewBottomLayoutConstraint.constant = padding
|
||||
self.composeToolbarViewBottomLayoutConstraint.constant = endFrame.height
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
self.updateKeyboardBackground(isKeyboardDisplay: isShow)
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// bind auto-complete
|
||||
viewModel.autoCompletion
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] autoCompletion in
|
||||
guard let self = self else { return }
|
||||
guard let textEditorView = self.textEditorView() else { return }
|
||||
if self.autoCompletionViewController.view.superview == nil {
|
||||
self.autoCompletionViewController.view.frame = self.view.bounds
|
||||
self.autoCompletionViewController.willMove(toParent: self)
|
||||
// add to container view. seealso: `viewDidLayoutSubviews()`
|
||||
textEditorView.superview!.addSubview(self.autoCompletionViewController.view)
|
||||
self.autoCompletionViewController.didMove(toParent: self)
|
||||
self.autoCompletionViewController.view.isHidden = true
|
||||
}
|
||||
self.autoCompletionViewController.view.isHidden = autoCompletion == nil
|
||||
guard let autoCompletion = autoCompletion else { return }
|
||||
self.autoCompletionViewController.view.frame.origin.y = autoCompletion.textBoundingRect.maxY
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// bind publish bar button state
|
||||
viewModel.isPublishBarButtonItemEnabled
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
@ -382,6 +409,18 @@ extension ComposeViewController {
|
|||
viewModel.traitCollectionDidChangePublisher.send()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
// pin autoCompletionViewController frame to window
|
||||
if let containerView = autoCompletionViewController.view.superview {
|
||||
let viewFrameInWindow = containerView.convert(autoCompletionViewController.view.frame, to: nil)
|
||||
if viewFrameInWindow.origin.x != 0 {
|
||||
autoCompletionViewController.view.frame.origin.x = -viewFrameInWindow.origin.x
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ComposeViewController {
|
||||
|
@ -600,10 +639,8 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
|
|||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string)
|
||||
|
||||
let stringRange = NSRange(location: 0, length: string.length)
|
||||
let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))")
|
||||
// accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect
|
||||
// precondition :\B with following space
|
||||
let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))")
|
||||
let highlightMatches = string.matches(pattern: MastodonRegex.highlightPattern)
|
||||
let emojiMatches = string.matches(pattern: MastodonRegex.emojiPattern)
|
||||
// only accept http/https scheme
|
||||
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
|
||||
|
||||
|
@ -729,6 +766,97 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
|
|||
|
||||
}
|
||||
|
||||
// MARK: - TextEditorViewChangeObserver
|
||||
extension ComposeViewController: TextEditorViewChangeObserver {
|
||||
|
||||
func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
|
||||
guard var autoCompeletion = ComposeViewController.scanAutoCompletion(textEditorView: textEditorView) else {
|
||||
viewModel.autoCompletion.value = nil
|
||||
return
|
||||
}
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompeletion.toHighlightEndString), String(autoCompeletion.toCursorString))
|
||||
|
||||
var glyphRange = NSRange()
|
||||
textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompeletion.toCursorRange, in: textEditorView.text), actualGlyphRange: &glyphRange)
|
||||
let textContainer = textEditorView.layoutManager.textContainers[0]
|
||||
let textBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||
let textBoundingRectInWindow = textEditorView.convert(textBoundingRect, to: nil)
|
||||
|
||||
let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes.value
|
||||
guard textBoundingRectInWindow.size != .zero else {
|
||||
viewModel.autoCompleteRetryLayoutTimes.value += 1
|
||||
// avoid infinite loop
|
||||
guard retryLayoutTimes < 3 else { return }
|
||||
// needs retry calculate layout when the rect position changing
|
||||
DispatchQueue.main.async {
|
||||
self.textEditorView(textEditorView, didChangeWithChangeResult: changeResult)
|
||||
}
|
||||
return
|
||||
}
|
||||
viewModel.autoCompleteRetryLayoutTimes.value = 0
|
||||
|
||||
// set bounding rect and trigger layout
|
||||
autoCompeletion.textBoundingRect = textBoundingRect
|
||||
viewModel.autoCompletion.value = autoCompeletion
|
||||
}
|
||||
|
||||
struct AutoCompletion {
|
||||
let toCursorRange: Range<String.Index>
|
||||
let toCursorString: Substring
|
||||
let toHighlightEndRange: Range<String.Index>
|
||||
let toHighlightEndString: Substring
|
||||
|
||||
var textBoundingRect: CGRect = .zero
|
||||
}
|
||||
|
||||
private static func scanAutoCompletion(textEditorView: TextEditorView) -> AutoCompletion? {
|
||||
let text = textEditorView.text
|
||||
let cursorLocation = textEditorView.selectedRange.location
|
||||
let cursorIndex = text.index(text.startIndex, offsetBy: cursorLocation)
|
||||
guard cursorLocation > 0, !text.isEmpty else { return nil }
|
||||
|
||||
let _highlighStartIndex: String.Index? = {
|
||||
var index = text.index(text.startIndex, offsetBy: cursorLocation - 1)
|
||||
while index > text.startIndex {
|
||||
let char = text[index]
|
||||
if char == "@" || char == "#" {
|
||||
return index
|
||||
}
|
||||
index = text.index(before: index)
|
||||
}
|
||||
assert(index == text.startIndex)
|
||||
let char = text[index]
|
||||
if char == "@" || char == "#" {
|
||||
return index
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
guard let highlighStartIndex = _highlighStartIndex else { return nil }
|
||||
let scanRange = NSRange(highlighStartIndex..<text.endIndex, in: text)
|
||||
|
||||
guard let match = text.firstMatch(pattern: MastodonRegex.highlightPattern, options: [], range: scanRange) else { return nil }
|
||||
let matchRange = match.range(at: 0)
|
||||
let matchStartIndex = text.index(text.startIndex, offsetBy: matchRange.location)
|
||||
let matchEndIndex = text.index(matchStartIndex, offsetBy: matchRange.length)
|
||||
|
||||
guard matchStartIndex == highlighStartIndex, matchEndIndex >= cursorIndex else { return nil }
|
||||
let toCursorRange = highlighStartIndex..<cursorIndex
|
||||
let toCursorString = text[toCursorRange]
|
||||
let toHighlightEndRange = matchStartIndex..<matchEndIndex
|
||||
let toHighlightEndString = text[toHighlightEndRange]
|
||||
let autoCompletion = AutoCompletion(
|
||||
toCursorRange: toCursorRange,
|
||||
toCursorString: toCursorString,
|
||||
toHighlightEndRange: toHighlightEndRange,
|
||||
toHighlightEndString: toHighlightEndString
|
||||
)
|
||||
return autoCompletion
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - ComposeToolbarViewDelegate
|
||||
extension ComposeViewController: ComposeToolbarViewDelegate {
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ extension ComposeViewModel {
|
|||
dependency: NeedsDependency,
|
||||
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
|
||||
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
|
||||
textEditorViewChangeObserver: TextEditorViewChangeObserver,
|
||||
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
|
||||
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
|
||||
composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
|
||||
|
@ -30,6 +31,7 @@ extension ComposeViewModel {
|
|||
repliedToCellFrameSubscriber: repliedToCellFrame,
|
||||
customEmojiPickerInputViewModel: customEmojiPickerInputViewModel,
|
||||
textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate,
|
||||
textEditorViewChangeObserver: textEditorViewChangeObserver,
|
||||
composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate,
|
||||
composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate,
|
||||
composeStatusNewPollOptionCollectionViewCellDelegate: composeStatusNewPollOptionCollectionViewCellDelegate,
|
||||
|
|
|
@ -31,6 +31,8 @@ final class ComposeViewModel {
|
|||
let activeAuthenticationBox: CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>
|
||||
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make intial event emit
|
||||
let repliedToCellFrame = CurrentValueSubject<CGRect, Never>(.zero)
|
||||
let autoCompleteRetryLayoutTimes = CurrentValueSubject<Int, Never>(0)
|
||||
let autoCompletion = CurrentValueSubject<ComposeViewController.AutoCompletion?, Never>(nil)
|
||||
|
||||
// output
|
||||
var diffableDataSource: UICollectionViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>!
|
||||
|
|
Loading…
Reference in New Issue