mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-11 12:51:15 +02:00
Co-authored-by: maria-rcks <maria@kuuro.net> Co-authored-by: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> Co-authored-by: Vitalii Yehorov <vitalyiegorov@gmail.com> Co-authored-by: Jake Leventhal <jakeleventhal@me.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Alex Southwell <saphid@gmail.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Nicholas Wasmiller <derped@mineperial.com> Co-authored-by: PB <poilmb@gmail.com> Co-authored-by: Exotic <118054752+extoci@users.noreply.github.com> Co-authored-by: Yash Singh <saiansh2525@gmail.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Theo Browne <me@t3.gg> Co-authored-by: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Co-authored-by: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Co-authored-by: scratchyone <scratchywon@gmail.com> Co-authored-by: Dominic Roy <dominic@sdko.org> Co-authored-by: chukfinley <chuk@chuk.dev> Co-authored-by: Primož Ajdišek <bigpod@bigpod.si> Co-authored-by: benthecarman <benthecarman@live.com> Co-authored-by: NaveDanan <nave0712@gmail.com> Co-authored-by: aaditagrawal <103925638+aaditagrawal@users.noreply.github.com> Co-authored-by: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Co-authored-by: Nick Anisimov <n.anisimov.23@gmail.com> Co-authored-by: MacKinley Smith <smithmackinley@gmail.com> Co-authored-by: Yordis Prieto <yordis.prieto@gmail.com> Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: AKolenda <akole779@mtroyal.ca> Co-authored-by: Guillermo Casanova <75276669+Gigioxx@users.noreply.github.com> Co-authored-by: Otavio Salvador <otavio@ossystems.com.br> Co-authored-by: Shirish Pothi <183252392+shirishpothi@users.noreply.github.com> Co-authored-by: Ishaan Kothari <ishaanko.mail@gmail.com> Co-authored-by: Bob Fowler <bob@rjf.ca> Co-authored-by: Anton Bezdenezhnykh <gamer392@yandex.ru> Co-authored-by: ValeraZSD <48602572+ValeraZSD@users.noreply.github.com> Co-authored-by: Ephraim <ephraim39hr14m@gmail.com> Co-authored-by: Ryan Ilano <ryanilano@users.noreply.github.com> Co-authored-by: Alex <me@pixp.cc> Co-authored-by: maco <gosarmarcel7@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Tristan Knight <admin@snappeh.com> Co-authored-by: PR Batch Tester <agent@local.test> Co-authored-by: oliver <97427849+flamboh@users.noreply.github.com> Co-authored-by: kamkm <99585688+Kamkmgamer@users.noreply.github.com> Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1302 lines
44 KiB
Swift
1302 lines
44 KiB
Swift
import ExpoModulesCore
|
|
import UIKit
|
|
|
|
private struct ComposerTokenPayload: Decodable {
|
|
let type: String
|
|
let source: String
|
|
let label: String
|
|
let iconUri: String?
|
|
let accent: String?
|
|
let symbol: String?
|
|
let detail: String?
|
|
let start: Int
|
|
let end: Int
|
|
}
|
|
|
|
private struct ComposerSelectionPayload: Decodable {
|
|
let start: Int
|
|
let end: Int
|
|
}
|
|
|
|
private struct ComposerControlledDocumentPayload: Decodable {
|
|
let value: String
|
|
let selection: ComposerSelectionPayload?
|
|
let tokensJson: String
|
|
let mostRecentEventCount: Int
|
|
let isNativeEcho: Bool
|
|
}
|
|
|
|
private struct ComposerThemePayload: Decodable {
|
|
let text: String
|
|
let placeholder: String
|
|
let chipBackground: String
|
|
let chipBorder: String
|
|
let chipText: String
|
|
let skillBackground: String
|
|
let skillBorder: String
|
|
let skillText: String
|
|
let fileTint: String
|
|
}
|
|
|
|
private struct ComposerChipStyle {
|
|
let tint: UIColor
|
|
let backgroundColor: UIColor
|
|
let borderColor: UIColor
|
|
let textColor: UIColor
|
|
}
|
|
|
|
private enum ComposerEnterBehavior: String {
|
|
case send
|
|
case newline
|
|
}
|
|
|
|
private final class ComposerTextAttachment: NSTextAttachment {
|
|
let source: String
|
|
let label: String
|
|
|
|
init(source: String, label: String, image: UIImage, size: CGSize, baselineOffset: CGFloat) {
|
|
self.source = source
|
|
self.label = label
|
|
super.init(data: nil, ofType: nil)
|
|
self.image = image
|
|
bounds = CGRect(x: 0, y: baselineOffset, width: size.width, height: size.height)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
nil
|
|
}
|
|
}
|
|
|
|
private final class ComposerContextAccessibilityElement: UIAccessibilityElement {
|
|
var activate: (() -> Bool)?
|
|
|
|
override func accessibilityActivate() -> Bool {
|
|
activate?() ?? false
|
|
}
|
|
}
|
|
|
|
private final class ComposerTextView: UITextView {
|
|
private static let pastedImageDirectoryName = "t3-composer-paste"
|
|
private static let stalePastedImageAge: TimeInterval = 60 * 60
|
|
private static let readOnlyActions = Set([
|
|
"cut:",
|
|
"delete:",
|
|
"paste:",
|
|
"redo:",
|
|
"toggleBoldface:",
|
|
"toggleItalics:",
|
|
"toggleUnderline:",
|
|
"undo:",
|
|
])
|
|
|
|
var onPasteImages: (([String]) -> Void)?
|
|
var onPasteContext: (([String: String]) -> Void)?
|
|
var onPasteText: ((String, NSRange) -> Void)?
|
|
var clipboardFragment = ""
|
|
var onAttributedMutation: (() -> Void)?
|
|
var onSubmit: ((Bool) -> Void)?
|
|
var isReadOnly = false
|
|
var textPasteThresholdBytes = 0
|
|
var maxInputChars = Int.max
|
|
var enterBehavior: ComposerEnterBehavior = .send
|
|
/// Shortcut HUD titles. JS supplies what the two sends actually do right now
|
|
/// ("Queue Message" / "Steer Message"), so the iPad Command-hold list names
|
|
/// the outcome rather than a generic "Send".
|
|
var submitTitle = "Send Message"
|
|
var alternateSubmitTitle = "Send Message"
|
|
private var bypassTextPasteInterception = false
|
|
|
|
override var keyCommands: [UIKeyCommand]? {
|
|
var commands = super.keyCommands ?? []
|
|
guard !isReadOnly, markedTextRange == nil else { return commands }
|
|
// The plainer chord always performs the configured follow-up behavior and
|
|
// the more-modified one performs its opposite, so Command is the "other
|
|
// way" modifier whichever Return behavior is configured.
|
|
if enterBehavior == .send {
|
|
let submitOnReturn = UIKeyCommand(
|
|
input: "\r",
|
|
modifierFlags: [],
|
|
action: #selector(submitMessage(_:))
|
|
)
|
|
submitOnReturn.discoverabilityTitle = submitTitle
|
|
submitOnReturn.wantsPriorityOverSystemBehavior = true
|
|
commands.append(submitOnReturn)
|
|
|
|
let submitAlternate = UIKeyCommand(
|
|
input: "\r",
|
|
modifierFlags: .command,
|
|
action: #selector(submitMessageAlternate(_:))
|
|
)
|
|
submitAlternate.discoverabilityTitle = alternateSubmitTitle
|
|
submitAlternate.wantsPriorityOverSystemBehavior = true
|
|
commands.append(submitAlternate)
|
|
|
|
let newline = UIKeyCommand(
|
|
input: "\r",
|
|
modifierFlags: .shift,
|
|
action: #selector(insertNewline(_:))
|
|
)
|
|
newline.discoverabilityTitle = "New Line"
|
|
newline.wantsPriorityOverSystemBehavior = true
|
|
commands.append(newline)
|
|
} else {
|
|
let submit = UIKeyCommand(
|
|
input: "\r",
|
|
modifierFlags: .command,
|
|
action: #selector(submitMessage(_:))
|
|
)
|
|
submit.discoverabilityTitle = submitTitle
|
|
submit.wantsPriorityOverSystemBehavior = true
|
|
commands.append(submit)
|
|
|
|
let submitAlternate = UIKeyCommand(
|
|
input: "\r",
|
|
modifierFlags: [.command, .shift],
|
|
action: #selector(submitMessageAlternate(_:))
|
|
)
|
|
submitAlternate.discoverabilityTitle = alternateSubmitTitle
|
|
submitAlternate.wantsPriorityOverSystemBehavior = true
|
|
commands.append(submitAlternate)
|
|
}
|
|
if textPasteThresholdBytes > 0 {
|
|
let pasteAsText = UIKeyCommand(
|
|
input: "v",
|
|
modifierFlags: [.command, .shift],
|
|
action: #selector(pasteInline(_:))
|
|
)
|
|
pasteAsText.discoverabilityTitle = "Paste as Text"
|
|
pasteAsText.wantsPriorityOverSystemBehavior = true
|
|
commands.append(pasteAsText)
|
|
}
|
|
return commands
|
|
}
|
|
|
|
@objc private func submitMessage(_ sender: UIKeyCommand) {
|
|
guard !isReadOnly, markedTextRange == nil else { return }
|
|
onSubmit?(false)
|
|
}
|
|
|
|
@objc private func submitMessageAlternate(_ sender: UIKeyCommand) {
|
|
guard !isReadOnly, markedTextRange == nil else { return }
|
|
onSubmit?(true)
|
|
}
|
|
|
|
@objc private func insertNewline(_ sender: UIKeyCommand) {
|
|
guard !isReadOnly, markedTextRange == nil else { return }
|
|
insertText("\n")
|
|
}
|
|
|
|
@objc private func pasteInline(_ sender: UIKeyCommand) {
|
|
guard !isReadOnly else {
|
|
return
|
|
}
|
|
bypassTextPasteInterception = true
|
|
defer { bypassTextPasteInterception = false }
|
|
paste(sender)
|
|
}
|
|
|
|
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
|
if action == #selector(submitMessage(_:)) || action == #selector(insertNewline(_:)) {
|
|
return isEditable && !isReadOnly && markedTextRange == nil
|
|
}
|
|
if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) {
|
|
return false
|
|
}
|
|
if action == #selector(paste(_:)) {
|
|
let pasteboard = UIPasteboard.general
|
|
if pasteboard.hasImages ||
|
|
pasteboard.itemProviders.contains(where: {
|
|
$0.canLoadObject(ofClass: UIImage.self)
|
|
}) {
|
|
return true
|
|
}
|
|
}
|
|
return super.canPerformAction(action, withSender: sender)
|
|
}
|
|
|
|
override func paste(_ sender: Any?) {
|
|
guard !isReadOnly else {
|
|
return
|
|
}
|
|
let pasteboard = UIPasteboard.general
|
|
let context = T3ComposerClipboard.read()
|
|
if !context["fragment", default: ""].isEmpty || context["html", default: ""].contains("data-t3-context-fragment=") {
|
|
onPasteContext?(context)
|
|
return
|
|
}
|
|
let imageProviders = pasteboard.itemProviders.filter {
|
|
$0.canLoadObject(ofClass: UIImage.self)
|
|
}
|
|
if !imageProviders.isEmpty {
|
|
loadImages(from: imageProviders)
|
|
return
|
|
}
|
|
|
|
let images = pasteboard.images ?? []
|
|
if !images.isEmpty {
|
|
let urls = images.compactMap(Self.writeTemporaryImage)
|
|
if !urls.isEmpty {
|
|
onPasteImages?(urls)
|
|
return
|
|
}
|
|
}
|
|
if !bypassTextPasteInterception,
|
|
let text = pasteboard.string, shouldInterceptTextPaste(text) {
|
|
onPasteText?(text, selectedRange)
|
|
return
|
|
}
|
|
super.paste(sender)
|
|
}
|
|
|
|
private func shouldInterceptTextPaste(_ text: String) -> Bool {
|
|
guard textPasteThresholdBytes > 0, !text.isEmpty else { return false }
|
|
let pastedLength = (text as NSString).length
|
|
if pastedLength >= textPasteThresholdBytes || text.utf8.count >= textPasteThresholdBytes {
|
|
return true
|
|
}
|
|
// Chips occupy one display character but expand to their source in the
|
|
// submitted message. Measure that source, including the replaced selection.
|
|
let sourceLength = sourceOffset(forDisplayOffset: attributedText.length)
|
|
let selectedLength = sourceOffset(forDisplayOffset: NSMaxRange(selectedRange)) -
|
|
sourceOffset(forDisplayOffset: selectedRange.location)
|
|
return sourceLength - selectedLength + pastedLength > maxInputChars
|
|
}
|
|
|
|
override func deleteBackward() {
|
|
guard !isReadOnly else {
|
|
return
|
|
}
|
|
guard selectedRange.length == 0, selectedRange.location > 0 else {
|
|
super.deleteBackward()
|
|
return
|
|
}
|
|
|
|
let previousOffset = selectedRange.location - 1
|
|
if textStorage.attribute(.attachment, at: previousOffset, effectiveRange: nil)
|
|
is ComposerTextAttachment {
|
|
replaceDisplayRange(NSRange(location: previousOffset, length: 1))
|
|
return
|
|
}
|
|
|
|
super.deleteBackward()
|
|
}
|
|
|
|
private func replaceDisplayRange(_ range: NSRange) {
|
|
guard let start = position(from: beginningOfDocument, offset: range.location),
|
|
let end = position(from: start, offset: range.length),
|
|
let textRange = textRange(from: start, to: end) else {
|
|
return
|
|
}
|
|
replace(textRange, withText: "")
|
|
}
|
|
|
|
func loadImages(from providers: [NSItemProvider]) {
|
|
let group = DispatchGroup()
|
|
let lock = NSLock()
|
|
var images = [UIImage?](repeating: nil, count: providers.count)
|
|
|
|
for (index, provider) in providers.enumerated() {
|
|
group.enter()
|
|
provider.loadObject(ofClass: UIImage.self) { object, _ in
|
|
defer { group.leave() }
|
|
guard let image = object as? UIImage else {
|
|
return
|
|
}
|
|
lock.lock()
|
|
images[index] = image
|
|
lock.unlock()
|
|
}
|
|
}
|
|
|
|
group.notify(queue: .main) { [weak self] in
|
|
guard let self, !self.isReadOnly else {
|
|
return
|
|
}
|
|
let urls = images.compactMap { $0 }.compactMap(Self.writeTemporaryImage)
|
|
if !urls.isEmpty {
|
|
self.onPasteImages?(urls)
|
|
}
|
|
}
|
|
}
|
|
|
|
override func copy(_ sender: Any?) {
|
|
guard selectedRange.length > 0 else {
|
|
return super.copy(sender)
|
|
}
|
|
T3ComposerClipboard.write(text: serializedText(in: selectedRange), fragment: clipboardFragment)
|
|
}
|
|
|
|
override func cut(_ sender: Any?) {
|
|
guard !isReadOnly else {
|
|
return
|
|
}
|
|
guard isEditable, selectedRange.length > 0 else {
|
|
return super.cut(sender)
|
|
}
|
|
copy(sender)
|
|
textStorage.replaceCharacters(in: selectedRange, with: "")
|
|
selectedRange = NSRange(location: selectedRange.location, length: 0)
|
|
onAttributedMutation?()
|
|
}
|
|
|
|
func serializedText() -> String {
|
|
serializedText(in: NSRange(location: 0, length: attributedText.length))
|
|
}
|
|
|
|
func serializedText(in range: NSRange) -> String {
|
|
guard range.length > 0 else {
|
|
return ""
|
|
}
|
|
|
|
let source = NSMutableString()
|
|
let nsString = attributedText.string as NSString
|
|
var cursor = range.location
|
|
let end = NSMaxRange(range)
|
|
attributedText.enumerateAttribute(.attachment, in: range) { value, attachmentRange, _ in
|
|
if attachmentRange.location > cursor {
|
|
source.append(
|
|
nsString.substring(
|
|
with: NSRange(location: cursor, length: attachmentRange.location - cursor)
|
|
)
|
|
)
|
|
}
|
|
if let attachment = value as? ComposerTextAttachment {
|
|
source.append(attachment.source)
|
|
} else {
|
|
source.append(nsString.substring(with: attachmentRange))
|
|
}
|
|
cursor = NSMaxRange(attachmentRange)
|
|
}
|
|
if cursor < end {
|
|
source.append(nsString.substring(with: NSRange(location: cursor, length: end - cursor)))
|
|
}
|
|
return source as String
|
|
}
|
|
|
|
func sourceOffset(forDisplayOffset displayOffset: Int) -> Int {
|
|
let boundedOffset = max(0, min(attributedText.length, displayOffset))
|
|
if boundedOffset == 0 {
|
|
return 0
|
|
}
|
|
|
|
var sourceOffset = 0
|
|
let range = NSRange(location: 0, length: boundedOffset)
|
|
attributedText.enumerateAttribute(.attachment, in: range) { value, attributeRange, _ in
|
|
if let attachment = value as? ComposerTextAttachment {
|
|
sourceOffset += (attachment.source as NSString).length
|
|
} else {
|
|
sourceOffset += attributeRange.length
|
|
}
|
|
}
|
|
return sourceOffset
|
|
}
|
|
|
|
private static func writeTemporaryImage(_ image: UIImage) -> String? {
|
|
guard let data = image.pngData() else {
|
|
return nil
|
|
}
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(pastedImageDirectoryName, isDirectory: true)
|
|
do {
|
|
try FileManager.default.createDirectory(
|
|
at: directory,
|
|
withIntermediateDirectories: true
|
|
)
|
|
removeStaleTemporaryImages(in: directory)
|
|
let url = directory.appendingPathComponent("\(UUID().uuidString).png")
|
|
try data.write(to: url, options: .atomic)
|
|
return url.absoluteString
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func removeStaleTemporaryImages(in directory: URL) {
|
|
let cutoff = Date().addingTimeInterval(-stalePastedImageAge)
|
|
guard let urls = try? FileManager.default.contentsOfDirectory(
|
|
at: directory,
|
|
includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else {
|
|
return
|
|
}
|
|
|
|
for url in urls {
|
|
guard
|
|
let values = try? url.resourceValues(
|
|
forKeys: [.contentModificationDateKey, .isRegularFileKey]
|
|
),
|
|
values.isRegularFile == true,
|
|
let modifiedAt = values.contentModificationDate,
|
|
modifiedAt < cutoff
|
|
else {
|
|
continue
|
|
}
|
|
try? FileManager.default.removeItem(at: url)
|
|
}
|
|
}
|
|
}
|
|
|
|
public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDropDelegate, UIGestureRecognizerDelegate {
|
|
private let textView = ComposerTextView()
|
|
private let placeholderLabel = UILabel()
|
|
private var value = ""
|
|
private var tokensJson = "[]"
|
|
private var tokens: [ComposerTokenPayload] = []
|
|
private var requestedSelection: ComposerSelectionPayload?
|
|
private var theme = ComposerThemePayload(
|
|
text: "#262626",
|
|
placeholder: "#8e8e93",
|
|
chipBackground: "#f2f2f7",
|
|
chipBorder: "#dedee3",
|
|
chipText: "#262626",
|
|
skillBackground: "#f9e8fb",
|
|
skillBorder: "#e5a6eb",
|
|
skillText: "#a21caf",
|
|
fileTint: "#737373"
|
|
)
|
|
private var fontFamily = "DMSans-Regular"
|
|
private var fontSize: CGFloat = 14
|
|
private var lineHeight: CGFloat = 20
|
|
private var contentInsetVertical: CGFloat = 0
|
|
private var shouldAutoFocus = false
|
|
private var didAutoFocus = false
|
|
private var isReadOnly = false
|
|
private var isApplyingControlledValue = false
|
|
private var nativeEventCount = 0
|
|
private var lastContentSize = CGSize.zero
|
|
private var iconImages: [String: UIImage] = [:]
|
|
private var pendingIconUris = Set<String>()
|
|
private var tokensNeedRebuild = false
|
|
private var chipsNeedMeasuredWidth = false
|
|
|
|
let onComposerChange = EventDispatcher()
|
|
let onComposerSelectionChange = EventDispatcher()
|
|
let onComposerFocus = EventDispatcher()
|
|
let onComposerBlur = EventDispatcher()
|
|
let onComposerSubmit = EventDispatcher()
|
|
let onComposerPasteImages = EventDispatcher()
|
|
let onComposerContextPress = EventDispatcher()
|
|
let onComposerPasteContext = EventDispatcher()
|
|
let onComposerPasteText = EventDispatcher()
|
|
let onComposerContentSizeChange = EventDispatcher()
|
|
|
|
public required init(appContext: AppContext? = nil) {
|
|
super.init(appContext: appContext)
|
|
|
|
clipsToBounds = false
|
|
textView.delegate = self
|
|
textView.textDropDelegate = self
|
|
textView.backgroundColor = .clear
|
|
textView.textContainerInset = .zero
|
|
textView.textContainer.lineFragmentPadding = 0
|
|
textView.keyboardDismissMode = .interactive
|
|
textView.alwaysBounceVertical = false
|
|
textView.showsVerticalScrollIndicator = true
|
|
textView.adjustsFontForContentSizeCategory = true
|
|
textView.onPasteImages = { [weak self] urls in
|
|
self?.onComposerPasteImages(["uris": urls])
|
|
}
|
|
textView.onPasteContext = { [weak self] context in
|
|
guard let self else { return }
|
|
let selection = self.sourceSelection()
|
|
self.nativeEventCount += 1
|
|
var payload: [String: Any] = context
|
|
payload["value"] = self.textView.serializedText()
|
|
payload["eventCount"] = self.nativeEventCount
|
|
payload["selection"] = ["start": selection.start, "end": selection.end]
|
|
self.onComposerPasteContext(payload)
|
|
}
|
|
textView.onPasteText = { [weak self] text, _ in
|
|
guard let self else { return }
|
|
let selection = self.sourceSelection()
|
|
self.nativeEventCount += 1
|
|
self.onComposerPasteText([
|
|
"value": self.textView.serializedText(),
|
|
"eventCount": self.nativeEventCount,
|
|
"text": text,
|
|
"selection": ["start": selection.start, "end": selection.end],
|
|
])
|
|
}
|
|
textView.onAttributedMutation = { [weak self] in
|
|
self?.emitTextChange()
|
|
}
|
|
textView.onSubmit = { [weak self] alternate in
|
|
self?.onComposerSubmit(["alternate": alternate])
|
|
}
|
|
let contextTap = UITapGestureRecognizer(target: self, action: #selector(openContext(_:)))
|
|
contextTap.cancelsTouchesInView = false
|
|
contextTap.delegate = self
|
|
textView.addGestureRecognizer(contextTap)
|
|
addSubview(textView)
|
|
|
|
placeholderLabel.numberOfLines = 0
|
|
placeholderLabel.adjustsFontForContentSizeCategory = true
|
|
addSubview(placeholderLabel)
|
|
applyTypography()
|
|
applyTheme()
|
|
}
|
|
|
|
@objc private func openContext(_ recognizer: UITapGestureRecognizer) {
|
|
guard let (index, attachment) = contextAttachment(at: recognizer.location(in: textView)) else {
|
|
textView.becomeFirstResponder()
|
|
return
|
|
}
|
|
openContext(index: index, attachment: attachment)
|
|
}
|
|
|
|
private func openContext(index: Int, attachment: ComposerTextAttachment) {
|
|
let start = textView.sourceOffset(forDisplayOffset: index)
|
|
onComposerContextPress(["source": attachment.source, "start": start, "end": start + (attachment.source as NSString).length])
|
|
}
|
|
|
|
public override var accessibilityElements: [Any]? {
|
|
get {
|
|
var elements: [Any] = [textView]
|
|
let layout = textView.layoutManager
|
|
textView.textStorage.enumerateAttribute(.attachment, in: NSRange(location: 0, length: textView.textStorage.length)) { value, range, _ in
|
|
guard let attachment = value as? ComposerTextAttachment else { return }
|
|
let glyphRange = layout.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
|
|
let rect = layout.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer)
|
|
.offsetBy(dx: textView.textContainerInset.left, dy: textView.textContainerInset.top)
|
|
guard rect.intersects(textView.bounds) else { return }
|
|
let element = ComposerContextAccessibilityElement(accessibilityContainer: self)
|
|
element.accessibilityLabel = attachment.label
|
|
element.accessibilityTraits = .button
|
|
element.accessibilityFrameInContainerSpace = textView.convert(rect, to: self)
|
|
element.activate = { [weak self] in
|
|
guard let self, range.location < self.textView.textStorage.length,
|
|
self.textView.textStorage.attribute(.attachment, at: range.location, effectiveRange: nil) as? ComposerTextAttachment === attachment else { return false }
|
|
self.openContext(index: range.location, attachment: attachment)
|
|
return true
|
|
}
|
|
elements.append(element)
|
|
}
|
|
return elements
|
|
}
|
|
set { super.accessibilityElements = newValue }
|
|
}
|
|
|
|
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
true
|
|
}
|
|
|
|
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
true
|
|
}
|
|
|
|
public func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
|
|
// A chip is text context, not an image to save to the camera roll.
|
|
!(textAttachment is ComposerTextAttachment)
|
|
}
|
|
|
|
private func contextAttachment(at point: CGPoint) -> (Int, ComposerTextAttachment)? {
|
|
let containerPoint = CGPoint(x: point.x - textView.textContainerInset.left, y: point.y - textView.textContainerInset.top)
|
|
let layout = textView.layoutManager
|
|
let index = layout.characterIndex(for: containerPoint, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
|
|
guard index < textView.textStorage.length,
|
|
let attachment = textView.textStorage.attribute(.attachment, at: index, effectiveRange: nil) as? ComposerTextAttachment else { return nil }
|
|
let glyphRange = layout.glyphRange(forCharacterRange: NSRange(location: index, length: 1), actualCharacterRange: nil)
|
|
guard layout.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer).contains(containerPoint) else { return nil }
|
|
return (index, attachment)
|
|
}
|
|
|
|
public override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
textView.frame = bounds
|
|
if chipsNeedMeasuredWidth, bounds.width > 0 {
|
|
chipsNeedMeasuredWidth = false
|
|
applyControlledDocument(force: true)
|
|
}
|
|
let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding
|
|
let placeholderY = textView.textContainerInset.top
|
|
let placeholderWidth = max(
|
|
0,
|
|
bounds.width - placeholderX - textView.textContainerInset.right -
|
|
textView.textContainer.lineFragmentPadding
|
|
)
|
|
placeholderLabel.frame = CGRect(
|
|
x: placeholderX,
|
|
y: placeholderY,
|
|
width: placeholderWidth,
|
|
height: max(lineHeight, placeholderLabel.font.lineHeight)
|
|
)
|
|
emitContentSizeIfNeeded()
|
|
}
|
|
|
|
func setClipboardFragment(_ fragment: String) {
|
|
textView.clipboardFragment = fragment
|
|
}
|
|
|
|
public override func didMoveToWindow() {
|
|
super.didMoveToWindow()
|
|
guard window != nil, shouldAutoFocus, !didAutoFocus else {
|
|
return
|
|
}
|
|
didAutoFocus = true
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.textView.becomeFirstResponder()
|
|
}
|
|
}
|
|
|
|
func setControlledDocumentJson(_ documentJson: String) {
|
|
guard let document = decode(ComposerControlledDocumentPayload.self, from: documentJson),
|
|
document.mostRecentEventCount >= nativeEventCount else {
|
|
return
|
|
}
|
|
if document.isNativeEcho && textView.serializedText() != document.value {
|
|
return
|
|
}
|
|
if tokensJson != document.tokensJson {
|
|
tokensJson = document.tokensJson
|
|
tokens = decode([ComposerTokenPayload].self, from: document.tokensJson) ?? []
|
|
tokensNeedRebuild = true
|
|
}
|
|
value = document.value
|
|
requestedSelection = document.selection
|
|
applyControlledDocument(force: tokensNeedRebuild)
|
|
applyRequestedSelection()
|
|
if tokensMatchCurrentValue() {
|
|
tokensNeedRebuild = false
|
|
}
|
|
}
|
|
|
|
func setThemeJson(_ themeJson: String) {
|
|
guard let nextTheme = decode(ComposerThemePayload.self, from: themeJson) else {
|
|
return
|
|
}
|
|
theme = nextTheme
|
|
applyTheme()
|
|
applyControlledDocument(force: true)
|
|
}
|
|
|
|
func setPlaceholder(_ placeholder: String) {
|
|
placeholderLabel.text = placeholder
|
|
setNeedsLayout()
|
|
}
|
|
|
|
func setFontFamily(_ fontFamily: String) {
|
|
self.fontFamily = fontFamily
|
|
applyTypography()
|
|
applyControlledDocument(force: true)
|
|
}
|
|
|
|
func setFontSize(_ fontSize: CGFloat) {
|
|
self.fontSize = fontSize
|
|
applyTypography()
|
|
applyControlledDocument(force: true)
|
|
}
|
|
|
|
func setLineHeight(_ lineHeight: CGFloat) {
|
|
self.lineHeight = lineHeight
|
|
applyTypography()
|
|
applyControlledDocument(force: true)
|
|
}
|
|
|
|
func setContentInsetVertical(_ contentInsetVertical: CGFloat) {
|
|
self.contentInsetVertical = contentInsetVertical
|
|
textView.textContainerInset = UIEdgeInsets(
|
|
top: contentInsetVertical,
|
|
left: 0,
|
|
bottom: contentInsetVertical,
|
|
right: 0
|
|
)
|
|
setNeedsLayout()
|
|
}
|
|
|
|
func setEditable(_ editable: Bool) {
|
|
textView.isEditable = editable
|
|
}
|
|
|
|
func setReadOnly(_ readOnly: Bool) {
|
|
isReadOnly = readOnly
|
|
textView.isReadOnly = readOnly
|
|
}
|
|
|
|
func setScrollEnabled(_ scrollEnabled: Bool) {
|
|
textView.isScrollEnabled = scrollEnabled
|
|
}
|
|
|
|
func setAutoFocus(_ autoFocus: Bool) {
|
|
shouldAutoFocus = autoFocus
|
|
}
|
|
|
|
func setAutoCorrect(_ autoCorrect: Bool) {
|
|
textView.autocorrectionType = autoCorrect ? .yes : .no
|
|
}
|
|
|
|
func setSpellCheck(_ spellCheck: Bool) {
|
|
textView.spellCheckingType = spellCheck ? .yes : .no
|
|
}
|
|
|
|
func setEnterBehavior(_ behavior: String) {
|
|
textView.enterBehavior = ComposerEnterBehavior(rawValue: behavior) ?? .send
|
|
}
|
|
|
|
func setSubmitTitle(_ title: String) {
|
|
textView.submitTitle = title
|
|
}
|
|
|
|
func setAlternateSubmitTitle(_ title: String) {
|
|
textView.alternateSubmitTitle = title
|
|
}
|
|
|
|
func setTextPasteThresholdBytes(_ threshold: Int) {
|
|
textView.textPasteThresholdBytes = threshold
|
|
}
|
|
|
|
func setMaxInputChars(_ maxInputChars: Int) {
|
|
textView.maxInputChars = maxInputChars
|
|
}
|
|
|
|
func focusEditor() {
|
|
textView.becomeFirstResponder()
|
|
}
|
|
|
|
func blurEditor() {
|
|
textView.resignFirstResponder()
|
|
}
|
|
|
|
func setSelection(start: Int, end: Int) {
|
|
requestedSelection = ComposerSelectionPayload(start: start, end: end)
|
|
applyRequestedSelection()
|
|
}
|
|
|
|
public func textViewDidChange(_ textView: UITextView) {
|
|
emitTextChange()
|
|
}
|
|
|
|
public func textViewDidChangeSelection(_ textView: UITextView) {
|
|
guard !isApplyingControlledValue else {
|
|
return
|
|
}
|
|
restoreBaseTypingAttributes()
|
|
// UIKit moves the selection before textViewDidChange runs. Emitting here
|
|
// would pair the post-edit text with a pre-edit revision counter, so let
|
|
// the change event that follows carry both; only pure caret moves emit.
|
|
guard self.textView.serializedText() == value else {
|
|
return
|
|
}
|
|
emitSelection()
|
|
}
|
|
|
|
public func textView(
|
|
_ textView: UITextView,
|
|
shouldChangeTextIn range: NSRange,
|
|
replacementText text: String
|
|
) -> Bool {
|
|
restoreBaseTypingAttributes()
|
|
return !isReadOnly
|
|
}
|
|
|
|
public func textDroppableView(
|
|
_ textDroppableView: UIView & UITextDroppable,
|
|
proposalForDrop drop: UITextDropRequest
|
|
) -> UITextDropProposal {
|
|
guard !isReadOnly else {
|
|
return UITextDropProposal(operation: .cancel)
|
|
}
|
|
guard droppedImageProviders(in: drop) != nil else {
|
|
return drop.suggestedProposal
|
|
}
|
|
|
|
// The composer owns image drops so UIKit does not insert NSTextAttachments
|
|
// that the controlled plain-text value cannot represent.
|
|
let proposal = UITextDropProposal(operation: .copy)
|
|
proposal.dropAction = .insert
|
|
proposal.dropPerformer = .delegate
|
|
return proposal
|
|
}
|
|
|
|
public func textDroppableView(
|
|
_ textDroppableView: UIView & UITextDroppable,
|
|
willPerformDrop drop: UITextDropRequest
|
|
) {
|
|
guard !isReadOnly else {
|
|
return
|
|
}
|
|
guard let imageProviders = droppedImageProviders(in: drop) else {
|
|
return
|
|
}
|
|
textView.loadImages(from: imageProviders)
|
|
}
|
|
|
|
private func droppedImageProviders(in drop: UITextDropRequest) -> [NSItemProvider]? {
|
|
let providers = drop.dropSession.items.map(\.itemProvider)
|
|
guard !providers.isEmpty,
|
|
providers.allSatisfy({ $0.canLoadObject(ofClass: UIImage.self) }) else {
|
|
return nil
|
|
}
|
|
return providers
|
|
}
|
|
|
|
public func textViewDidBeginEditing(_ textView: UITextView) {
|
|
onComposerFocus()
|
|
}
|
|
|
|
public func textViewDidEndEditing(_ textView: UITextView) {
|
|
onComposerBlur()
|
|
}
|
|
|
|
private func applyControlledDocument(force: Bool = false) {
|
|
let currentSource = textView.serializedText()
|
|
guard force || currentSource != value || !documentMatchesExpectedTokens() else {
|
|
updatePlaceholderVisibility()
|
|
return
|
|
}
|
|
|
|
let previousSelection = sourceSelection()
|
|
isApplyingControlledValue = true
|
|
textView.attributedText = makeAttributedDocument()
|
|
let targetSelection = requestedSelection ?? previousSelection
|
|
requestedSelection = nil
|
|
textView.selectedRange = displayRange(for: targetSelection)
|
|
restoreBaseTypingAttributes()
|
|
isApplyingControlledValue = false
|
|
updatePlaceholderVisibility()
|
|
emitContentSizeIfNeeded()
|
|
}
|
|
|
|
private func makeAttributedDocument() -> NSAttributedString {
|
|
let result = NSMutableAttributedString()
|
|
let source = value as NSString
|
|
var cursor = 0
|
|
let validTokens = tokens.filter {
|
|
$0.start >= cursor &&
|
|
$0.end > $0.start &&
|
|
$0.end <= source.length &&
|
|
source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source
|
|
}
|
|
|
|
for token in validTokens {
|
|
if token.start < cursor {
|
|
continue
|
|
}
|
|
if token.start > cursor {
|
|
appendPlainText(
|
|
source.substring(with: NSRange(location: cursor, length: token.start - cursor)),
|
|
to: result
|
|
)
|
|
}
|
|
result.append(makeAttachmentString(token))
|
|
cursor = token.end
|
|
}
|
|
if cursor < source.length {
|
|
appendPlainText(
|
|
source.substring(with: NSRange(location: cursor, length: source.length - cursor)),
|
|
to: result
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func appendPlainText(_ text: String, to result: NSMutableAttributedString) {
|
|
result.append(NSAttributedString(string: text, attributes: baseAttributes()))
|
|
}
|
|
|
|
private func makeAttachmentString(_ token: ComposerTokenPayload) -> NSAttributedString {
|
|
let isSkill = token.type == "skill"
|
|
let accent = token.accent.flatMap { UIColor(composerHex: $0) }
|
|
let foreground = UIColor(composerHex: theme.chipText) ?? .label
|
|
let border = UIColor(composerHex: theme.chipBorder) ?? .separator
|
|
let tint = accent.map { blend($0, over: foreground, weight: 0.22) }
|
|
?? UIColor(composerHex: isSkill ? theme.skillText : theme.fileTint) ?? .secondaryLabel
|
|
let iconName = token.symbol ?? (isSkill ? "cube" : "doc")
|
|
let iconImage = token.iconUri.flatMap(iconImage(for:))
|
|
let style = ComposerChipStyle(
|
|
tint: tint,
|
|
backgroundColor: accent?.withAlphaComponent(0.11) ?? UIColor(
|
|
composerHex: isSkill ? theme.skillBackground : theme.chipBackground
|
|
) ?? .secondarySystemFill,
|
|
borderColor: accent.map { blend($0, over: border, weight: 0.34) } ?? UIColor(
|
|
composerHex: isSkill ? theme.skillBorder : theme.chipBorder
|
|
) ?? .separator,
|
|
textColor: tint
|
|
)
|
|
let image = renderChip(
|
|
label: token.label,
|
|
detail: token.detail,
|
|
iconName: iconName,
|
|
iconImage: iconImage,
|
|
style: style
|
|
)
|
|
let font = UIFont(name: fontFamily, size: fontSize)
|
|
?? UIFont.systemFont(ofSize: fontSize)
|
|
let baselineOffset = floor((font.capHeight - image.size.height) / 2)
|
|
let attachment = ComposerTextAttachment(
|
|
source: token.source,
|
|
label: token.label,
|
|
image: image,
|
|
size: image.size,
|
|
baselineOffset: baselineOffset
|
|
)
|
|
let attributedAttachment = NSMutableAttributedString(attachment: attachment)
|
|
attributedAttachment.addAttributes(
|
|
baseAttributes(),
|
|
range: NSRange(location: 0, length: attributedAttachment.length)
|
|
)
|
|
return attributedAttachment
|
|
}
|
|
|
|
/// Chip glyphs with no SF Symbol that reads correctly. A pull request would otherwise land on
|
|
/// `arrow.triangle.branch`, a road-sign fork that says "branch", not "pull request", so it is
|
|
/// drawn from the same lucide geometry web and Android use.
|
|
private static func vectorIcon(named name: String, size: CGFloat, color: UIColor) -> UIImage? {
|
|
guard name == "git-pull-request" else { return nil }
|
|
return UIGraphicsImageRenderer(size: CGSize(width: size, height: size)).image { _ in
|
|
let s = size / 24 // lucide authors on a 24pt grid.
|
|
let path = UIBezierPath()
|
|
for centre in [CGPoint(x: 18 * s, y: 18 * s), CGPoint(x: 6 * s, y: 6 * s)] {
|
|
path.append(UIBezierPath(arcCenter: centre, radius: 3 * s, startAngle: 0,
|
|
endAngle: .pi * 2, clockwise: true))
|
|
}
|
|
path.move(to: CGPoint(x: 13 * s, y: 6 * s))
|
|
path.addLine(to: CGPoint(x: 16 * s, y: 6 * s))
|
|
path.addCurve(to: CGPoint(x: 18 * s, y: 8 * s),
|
|
controlPoint1: CGPoint(x: 17.1 * s, y: 6 * s),
|
|
controlPoint2: CGPoint(x: 18 * s, y: 6.9 * s))
|
|
path.addLine(to: CGPoint(x: 18 * s, y: 15 * s))
|
|
path.move(to: CGPoint(x: 6 * s, y: 9 * s))
|
|
path.addLine(to: CGPoint(x: 6 * s, y: 21 * s))
|
|
path.lineWidth = 2 * s
|
|
path.lineCapStyle = .round
|
|
path.lineJoinStyle = .round
|
|
color.setStroke()
|
|
path.stroke()
|
|
}
|
|
}
|
|
|
|
private func renderChip(
|
|
label: String,
|
|
detail: String?,
|
|
iconName: String,
|
|
iconImage: UIImage?,
|
|
style: ComposerChipStyle
|
|
) -> UIImage {
|
|
// Kept in step with `T3ContextChipVectorIcon` in the markdown module: a chip drawn here and
|
|
// the same chip drawn in a sent message have to be the same picture.
|
|
let chipFontSize = fontSize * 0.86
|
|
let font = UIFont(name: "DMSans-Medium", size: chipFontSize)
|
|
?? UIFont.systemFont(ofSize: chipFontSize, weight: .medium)
|
|
let fallbackIcon = Self.vectorIcon(named: iconName, size: 14, color: style.textColor)
|
|
?? UIImage(
|
|
systemName: iconName,
|
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium)
|
|
)
|
|
let icon = iconImage ?? fallbackIcon
|
|
// The size reads as metadata, not part of the name, so it renders a step down from the
|
|
// label the way the web chip does.
|
|
let paragraph = NSMutableParagraphStyle()
|
|
paragraph.alignment = .left
|
|
let detailFont = UIFont(name: "DMSans-Medium", size: chipFontSize * 0.84)
|
|
?? UIFont.systemFont(ofSize: chipFontSize * 0.84, weight: .medium)
|
|
let attributedLabel = NSMutableAttributedString(
|
|
string: label,
|
|
attributes: [.font: font, .foregroundColor: style.textColor, .paragraphStyle: paragraph]
|
|
)
|
|
if let detail, !detail.isEmpty {
|
|
attributedLabel.append(
|
|
NSAttributedString(
|
|
string: " \(detail)",
|
|
attributes: [
|
|
.font: detailFont,
|
|
.foregroundColor: style.textColor,
|
|
.paragraphStyle: paragraph,
|
|
]
|
|
)
|
|
)
|
|
}
|
|
let iconWidth: CGFloat = icon == nil ? 0 : chipFontSize * 1.17
|
|
let iconGap: CGFloat = icon == nil ? 0 : chipFontSize * 0.33
|
|
let padding = chipFontSize * 0.5
|
|
let height = ceil(chipFontSize * 1.41)
|
|
// A long path would otherwise draw a chip wider than the composer and clip. Cap the label
|
|
// to the text the editor can actually show and truncate inside it, as Android's
|
|
// `maximumWidth` does, so the chip always fits the line it sits on.
|
|
let availableWidth = textView.textContainer.size.width > 0
|
|
? textView.textContainer.size.width - textView.textContainer.lineFragmentPadding * 2
|
|
: (textView.window?.bounds.width ?? bounds.width)
|
|
if availableWidth <= 0 {
|
|
chipsNeedMeasuredWidth = true
|
|
}
|
|
let maximumLabelWidth = max(chipFontSize * 3, availableWidth - padding * 2 - iconWidth - iconGap)
|
|
paragraph.lineBreakMode = .byTruncatingMiddle
|
|
attributedLabel.addAttribute(
|
|
.paragraphStyle,
|
|
value: paragraph,
|
|
range: NSRange(location: 0, length: attributedLabel.length)
|
|
)
|
|
let measured = attributedLabel.size()
|
|
let textSize = CGSize(width: min(measured.width, maximumLabelWidth), height: measured.height)
|
|
let width = ceil(padding * 2 + iconWidth + iconGap + textSize.width)
|
|
let format = UIGraphicsImageRendererFormat.preferred()
|
|
format.opaque = false
|
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format)
|
|
return renderer.image { context in
|
|
let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height))
|
|
let path = UIBezierPath(roundedRect: rect.insetBy(dx: 0.5, dy: 0.5), cornerRadius: chipFontSize * 0.5)
|
|
style.backgroundColor.setFill()
|
|
path.fill()
|
|
style.borderColor.setStroke()
|
|
path.lineWidth = 1
|
|
path.stroke()
|
|
|
|
var x = padding
|
|
if let icon {
|
|
let renderedIcon = iconImage == nil
|
|
? icon.withTintColor(style.tint, renderingMode: .alwaysOriginal)
|
|
: icon
|
|
renderedIcon.draw(
|
|
in: CGRect(x: x, y: (height - iconWidth) / 2, width: iconWidth, height: iconWidth)
|
|
)
|
|
x += iconWidth + iconGap
|
|
}
|
|
// Exactly the measured width: a spare pixel here would let a capped label draw past
|
|
// the cap instead of truncating inside it.
|
|
attributedLabel.draw(
|
|
in: CGRect(x: x, y: (height - textSize.height) / 2, width: textSize.width, height: textSize.height)
|
|
)
|
|
context.cgContext.setAllowsAntialiasing(true)
|
|
}
|
|
}
|
|
|
|
private func blend(_ accent: UIColor, over base: UIColor, weight: CGFloat) -> UIColor {
|
|
var ar: CGFloat = 0, ag: CGFloat = 0, ab: CGFloat = 0, aa: CGFloat = 0
|
|
var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0
|
|
accent.getRed(&ar, green: &ag, blue: &ab, alpha: &aa)
|
|
base.getRed(&br, green: &bg, blue: &bb, alpha: &ba)
|
|
return UIColor(
|
|
red: ar * weight + br * (1 - weight),
|
|
green: ag * weight + bg * (1 - weight),
|
|
blue: ab * weight + bb * (1 - weight),
|
|
alpha: aa * weight + ba * (1 - weight)
|
|
)
|
|
}
|
|
|
|
private func iconImage(for uri: String) -> UIImage? {
|
|
if let image = iconImages[uri] {
|
|
return image
|
|
}
|
|
guard !pendingIconUris.contains(uri), let url = URL(string: uri) else {
|
|
return nil
|
|
}
|
|
|
|
if url.isFileURL, let image = UIImage(contentsOfFile: url.path) {
|
|
iconImages[uri] = image
|
|
return image
|
|
}
|
|
|
|
pendingIconUris.insert(uri)
|
|
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
|
guard let self, let data, let image = UIImage(data: data) else {
|
|
DispatchQueue.main.async {
|
|
self?.pendingIconUris.remove(uri)
|
|
}
|
|
return
|
|
}
|
|
DispatchQueue.main.async {
|
|
self.pendingIconUris.remove(uri)
|
|
self.iconImages[uri] = image
|
|
self.applyControlledDocument(force: true)
|
|
}
|
|
}.resume()
|
|
return nil
|
|
}
|
|
|
|
private func baseAttributes() -> [NSAttributedString.Key: Any] {
|
|
let font = UIFont(name: fontFamily, size: fontSize)
|
|
?? UIFont.systemFont(ofSize: fontSize)
|
|
let paragraph = NSMutableParagraphStyle()
|
|
paragraph.minimumLineHeight = lineHeight
|
|
paragraph.maximumLineHeight = lineHeight
|
|
return [
|
|
.font: font,
|
|
.foregroundColor: UIColor(composerHex: theme.text) ?? .label,
|
|
.paragraphStyle: paragraph,
|
|
]
|
|
}
|
|
|
|
private func applyTypography() {
|
|
let font = UIFont(name: fontFamily, size: fontSize)
|
|
?? UIFont.systemFont(ofSize: fontSize)
|
|
textView.font = font
|
|
restoreBaseTypingAttributes()
|
|
placeholderLabel.font = font
|
|
setNeedsLayout()
|
|
}
|
|
|
|
private func restoreBaseTypingAttributes() {
|
|
guard textView.markedTextRange == nil else {
|
|
return
|
|
}
|
|
textView.typingAttributes = baseAttributes()
|
|
}
|
|
|
|
private func applyTheme() {
|
|
textView.textColor = UIColor(composerHex: theme.text) ?? .label
|
|
placeholderLabel.textColor = UIColor(composerHex: theme.placeholder) ?? .placeholderText
|
|
tintColor = UIColor.systemBlue
|
|
}
|
|
|
|
private func emitTextChange() {
|
|
guard !isApplyingControlledValue else {
|
|
return
|
|
}
|
|
value = textView.serializedText()
|
|
let selection = sourceSelection()
|
|
nativeEventCount += 1
|
|
onComposerChange([
|
|
"value": value,
|
|
"selection": ["start": selection.start, "end": selection.end],
|
|
"eventCount": nativeEventCount,
|
|
])
|
|
updatePlaceholderVisibility()
|
|
emitContentSizeIfNeeded()
|
|
}
|
|
|
|
private func emitSelection() {
|
|
// Caret moves advance the revision counter like text edits do: a
|
|
// controlled payload computed before this move is stale and must fail the
|
|
// revision guard instead of yanking the caret back mid-typing.
|
|
let currentValue = textView.serializedText()
|
|
let selection = sourceSelection()
|
|
nativeEventCount += 1
|
|
onComposerSelectionChange([
|
|
"value": currentValue,
|
|
"selection": ["start": selection.start, "end": selection.end],
|
|
"eventCount": nativeEventCount,
|
|
])
|
|
}
|
|
|
|
private func sourceSelection() -> ComposerSelectionPayload {
|
|
ComposerSelectionPayload(
|
|
start: textView.sourceOffset(forDisplayOffset: textView.selectedRange.location),
|
|
end: textView.sourceOffset(forDisplayOffset: NSMaxRange(textView.selectedRange))
|
|
)
|
|
}
|
|
|
|
private func displayRange(for selection: ComposerSelectionPayload) -> NSRange {
|
|
let start = displayOffset(forSourceOffset: selection.start)
|
|
let end = displayOffset(forSourceOffset: selection.end)
|
|
return NSRange(location: start, length: max(0, end - start))
|
|
}
|
|
|
|
private func displayOffset(forSourceOffset sourceOffset: Int) -> Int {
|
|
let boundedOffset = max(0, min((value as NSString).length, sourceOffset))
|
|
var collapsedLength = 0
|
|
for token in tokens where token.end <= boundedOffset {
|
|
collapsedLength += max(0, token.end - token.start - 1)
|
|
}
|
|
if let token = tokens.first(where: { $0.start < boundedOffset && boundedOffset < $0.end }) {
|
|
return token.start - collapsedLength + 1
|
|
}
|
|
return boundedOffset - collapsedLength
|
|
}
|
|
|
|
private func applyRequestedSelection() {
|
|
guard let requestedSelection else {
|
|
return
|
|
}
|
|
let nextRange = displayRange(for: requestedSelection)
|
|
guard nextRange.location <= textView.attributedText.length,
|
|
NSMaxRange(nextRange) <= textView.attributedText.length else {
|
|
return
|
|
}
|
|
self.requestedSelection = nil
|
|
// Programmatically assigning selectedRange resets the keyboard's
|
|
// autocorrect and predictive-text context even when the range is
|
|
// unchanged, so a no-op assignment must be skipped.
|
|
guard !NSEqualRanges(nextRange, textView.selectedRange) else {
|
|
return
|
|
}
|
|
isApplyingControlledValue = true
|
|
textView.selectedRange = nextRange
|
|
isApplyingControlledValue = false
|
|
}
|
|
|
|
private func updatePlaceholderVisibility() {
|
|
placeholderLabel.isHidden = !value.isEmpty
|
|
}
|
|
|
|
private func emitContentSizeIfNeeded() {
|
|
let nextSize = textView.contentSize
|
|
guard abs(nextSize.width - lastContentSize.width) > 0.5 ||
|
|
abs(nextSize.height - lastContentSize.height) > 0.5 else {
|
|
return
|
|
}
|
|
lastContentSize = nextSize
|
|
onComposerContentSizeChange(["width": nextSize.width, "height": nextSize.height])
|
|
}
|
|
|
|
private func decode<T: Decodable>(_ type: T.Type, from json: String) -> T? {
|
|
guard let data = json.data(using: .utf8) else {
|
|
return nil
|
|
}
|
|
return try? JSONDecoder().decode(type, from: data)
|
|
}
|
|
|
|
private func tokensMatchCurrentValue() -> Bool {
|
|
let source = value as NSString
|
|
return tokens.allSatisfy {
|
|
$0.start >= 0 &&
|
|
$0.end > $0.start &&
|
|
$0.end <= source.length &&
|
|
source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source
|
|
}
|
|
}
|
|
|
|
private func documentMatchesExpectedTokens() -> Bool {
|
|
let source = value as NSString
|
|
let expectedSources = tokens.compactMap { token -> String? in
|
|
guard token.start >= 0,
|
|
token.end > token.start,
|
|
token.end <= source.length,
|
|
source.substring(
|
|
with: NSRange(location: token.start, length: token.end - token.start)
|
|
) == token.source else {
|
|
return nil
|
|
}
|
|
return token.source
|
|
}
|
|
var renderedSources: [String] = []
|
|
textView.attributedText.enumerateAttribute(
|
|
.attachment,
|
|
in: NSRange(location: 0, length: textView.attributedText.length)
|
|
) { value, _, _ in
|
|
if let attachment = value as? ComposerTextAttachment {
|
|
renderedSources.append(attachment.source)
|
|
}
|
|
}
|
|
return renderedSources == expectedSources
|
|
}
|
|
}
|
|
|
|
private extension UIColor {
|
|
convenience init?(composerHex hex: String?) {
|
|
guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
|
|
return nil
|
|
}
|
|
if value.hasPrefix("#") {
|
|
value.removeFirst()
|
|
}
|
|
guard value.count == 6 || value.count == 8,
|
|
let raw = UInt64(value, radix: 16) else {
|
|
return nil
|
|
}
|
|
if value.count == 8 {
|
|
self.init(
|
|
red: CGFloat((raw >> 24) & 0xff) / 255,
|
|
green: CGFloat((raw >> 16) & 0xff) / 255,
|
|
blue: CGFloat((raw >> 8) & 0xff) / 255,
|
|
alpha: CGFloat(raw & 0xff) / 255
|
|
)
|
|
} else {
|
|
self.init(
|
|
red: CGFloat((raw >> 16) & 0xff) / 255,
|
|
green: CGFloat((raw >> 8) & 0xff) / 255,
|
|
blue: CGFloat(raw & 0xff) / 255,
|
|
alpha: 1
|
|
)
|
|
}
|
|
}
|
|
}
|