| 1 | import ExpoModulesCore |
| 2 | import UIKit |
| 3 | |
| 4 | private struct ComposerTokenPayload: Decodable { |
| 5 | let type: String |
| 6 | let source: String |
| 7 | let label: String |
| 8 | let iconUri: String? |
| 9 | let accent: String? |
| 10 | let symbol: String? |
| 11 | let detail: String? |
| 12 | let start: Int |
| 13 | let end: Int |
| 14 | } |
| 15 | |
| 16 | private struct ComposerSelectionPayload: Decodable { |
| 17 | let start: Int |
| 18 | let end: Int |
| 19 | } |
| 20 | |
| 21 | private struct ComposerControlledDocumentPayload: Decodable { |
| 22 | let value: String |
| 23 | let selection: ComposerSelectionPayload? |
| 24 | let tokensJson: String |
| 25 | let mostRecentEventCount: Int |
| 26 | let isNativeEcho: Bool |
| 27 | } |
| 28 | |
| 29 | private struct ComposerThemePayload: Decodable { |
| 30 | let text: String |
| 31 | let placeholder: String |
| 32 | let chipBackground: String |
| 33 | let chipBorder: String |
| 34 | let chipText: String |
| 35 | let skillBackground: String |
| 36 | let skillBorder: String |
| 37 | let skillText: String |
| 38 | let fileTint: String |
| 39 | } |
| 40 | |
| 41 | private struct ComposerChipStyle { |
| 42 | let tint: UIColor |
| 43 | let backgroundColor: UIColor |
| 44 | let borderColor: UIColor |
| 45 | let textColor: UIColor |
| 46 | } |
| 47 | |
| 48 | private enum ComposerEnterBehavior: String { |
| 49 | case send |
| 50 | case newline |
| 51 | } |
| 52 | |
| 53 | private final class ComposerTextAttachment: NSTextAttachment { |
| 54 | let source: String |
| 55 | let label: String |
| 56 | |
| 57 | init(source: String, label: String, image: UIImage, size: CGSize, baselineOffset: CGFloat) { |
| 58 | self.source = source |
| 59 | self.label = label |
| 60 | super.init(data: nil, ofType: nil) |
| 61 | self.image = image |
| 62 | bounds = CGRect(x: 0, y: baselineOffset, width: size.width, height: size.height) |
| 63 | } |
| 64 | |
| 65 | required init?(coder: NSCoder) { |
| 66 | nil |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | private final class ComposerContextAccessibilityElement: UIAccessibilityElement { |
| 71 | var activate: (() -> Bool)? |
| 72 | |
| 73 | override func accessibilityActivate() -> Bool { |
| 74 | activate?() ?? false |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | private final class ComposerTextView: UITextView { |
| 79 | private static let pastedImageDirectoryName = "t3-composer-paste" |
| 80 | private static let stalePastedImageAge: TimeInterval = 60 * 60 |
| 81 | private static let readOnlyActions = Set([ |
| 82 | "cut:", |
| 83 | "delete:", |
| 84 | "paste:", |
| 85 | "redo:", |
| 86 | "toggleBoldface:", |
| 87 | "toggleItalics:", |
| 88 | "toggleUnderline:", |
| 89 | "undo:", |
| 90 | ]) |
| 91 | |
| 92 | var onPasteImages: (([String]) -> Void)? |
| 93 | var onPasteContext: (([String: String]) -> Void)? |
| 94 | var onPasteText: ((String, NSRange) -> Void)? |
| 95 | var clipboardFragment = "" |
| 96 | var onAttributedMutation: (() -> Void)? |
| 97 | var onSubmit: ((Bool) -> Void)? |
| 98 | var isReadOnly = false |
| 99 | var textPasteThresholdBytes = 0 |
| 100 | var maxInputChars = Int.max |
| 101 | var enterBehavior: ComposerEnterBehavior = .send |
| 102 | /// Shortcut HUD titles. JS supplies what the two sends actually do right now |
| 103 | /// ("Queue Message" / "Steer Message"), so the iPad Command-hold list names |
| 104 | /// the outcome rather than a generic "Send". |
| 105 | var submitTitle = "Send Message" |
| 106 | var alternateSubmitTitle = "Send Message" |
| 107 | private var bypassTextPasteInterception = false |
| 108 | |
| 109 | override var keyCommands: [UIKeyCommand]? { |
| 110 | var commands = super.keyCommands ?? [] |
| 111 | guard !isReadOnly, markedTextRange == nil else { return commands } |
| 112 | // The plainer chord always performs the configured follow-up behavior and |
| 113 | // the more-modified one performs its opposite, so Command is the "other |
| 114 | // way" modifier whichever Return behavior is configured. |
| 115 | if enterBehavior == .send { |
| 116 | let submitOnReturn = UIKeyCommand( |
| 117 | input: "\r", |
| 118 | modifierFlags: [], |
| 119 | action: #selector(submitMessage(_:)) |
| 120 | ) |
| 121 | submitOnReturn.discoverabilityTitle = submitTitle |
| 122 | submitOnReturn.wantsPriorityOverSystemBehavior = true |
| 123 | commands.append(submitOnReturn) |
| 124 | |
| 125 | let submitAlternate = UIKeyCommand( |
| 126 | input: "\r", |
| 127 | modifierFlags: .command, |
| 128 | action: #selector(submitMessageAlternate(_:)) |
| 129 | ) |
| 130 | submitAlternate.discoverabilityTitle = alternateSubmitTitle |
| 131 | submitAlternate.wantsPriorityOverSystemBehavior = true |
| 132 | commands.append(submitAlternate) |
| 133 | |
| 134 | let newline = UIKeyCommand( |
| 135 | input: "\r", |
| 136 | modifierFlags: .shift, |
| 137 | action: #selector(insertNewline(_:)) |
| 138 | ) |
| 139 | newline.discoverabilityTitle = "New Line" |
| 140 | newline.wantsPriorityOverSystemBehavior = true |
| 141 | commands.append(newline) |
| 142 | } else { |
| 143 | let submit = UIKeyCommand( |
| 144 | input: "\r", |
| 145 | modifierFlags: .command, |
| 146 | action: #selector(submitMessage(_:)) |
| 147 | ) |
| 148 | submit.discoverabilityTitle = submitTitle |
| 149 | submit.wantsPriorityOverSystemBehavior = true |
| 150 | commands.append(submit) |
| 151 | |
| 152 | let submitAlternate = UIKeyCommand( |
| 153 | input: "\r", |
| 154 | modifierFlags: [.command, .shift], |
| 155 | action: #selector(submitMessageAlternate(_:)) |
| 156 | ) |
| 157 | submitAlternate.discoverabilityTitle = alternateSubmitTitle |
| 158 | submitAlternate.wantsPriorityOverSystemBehavior = true |
| 159 | commands.append(submitAlternate) |
| 160 | } |
| 161 | if textPasteThresholdBytes > 0 { |
| 162 | let pasteAsText = UIKeyCommand( |
| 163 | input: "v", |
| 164 | modifierFlags: [.command, .shift], |
| 165 | action: #selector(pasteInline(_:)) |
| 166 | ) |
| 167 | pasteAsText.discoverabilityTitle = "Paste as Text" |
| 168 | pasteAsText.wantsPriorityOverSystemBehavior = true |
| 169 | commands.append(pasteAsText) |
| 170 | } |
| 171 | return commands |
| 172 | } |
| 173 | |
| 174 | @objc private func submitMessage(_ sender: UIKeyCommand) { |
| 175 | guard !isReadOnly, markedTextRange == nil else { return } |
| 176 | onSubmit?(false) |
| 177 | } |
| 178 | |
| 179 | @objc private func submitMessageAlternate(_ sender: UIKeyCommand) { |
| 180 | guard !isReadOnly, markedTextRange == nil else { return } |
| 181 | onSubmit?(true) |
| 182 | } |
| 183 | |
| 184 | @objc private func insertNewline(_ sender: UIKeyCommand) { |
| 185 | guard !isReadOnly, markedTextRange == nil else { return } |
| 186 | insertText("\n") |
| 187 | } |
| 188 | |
| 189 | @objc private func pasteInline(_ sender: UIKeyCommand) { |
| 190 | guard !isReadOnly else { |
| 191 | return |
| 192 | } |
| 193 | bypassTextPasteInterception = true |
| 194 | defer { bypassTextPasteInterception = false } |
| 195 | paste(sender) |
| 196 | } |
| 197 | |
| 198 | override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { |
| 199 | if action == #selector(submitMessage(_:)) || action == #selector(insertNewline(_:)) { |
| 200 | return isEditable && !isReadOnly && markedTextRange == nil |
| 201 | } |
| 202 | if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) { |
| 203 | return false |
| 204 | } |
| 205 | if action == #selector(paste(_:)) { |
| 206 | let pasteboard = UIPasteboard.general |
| 207 | if pasteboard.hasImages || |
| 208 | pasteboard.itemProviders.contains(where: { |
| 209 | $0.canLoadObject(ofClass: UIImage.self) |
| 210 | }) { |
| 211 | return true |
| 212 | } |
| 213 | } |
| 214 | return super.canPerformAction(action, withSender: sender) |
| 215 | } |
| 216 | |
| 217 | override func paste(_ sender: Any?) { |
| 218 | guard !isReadOnly else { |
| 219 | return |
| 220 | } |
| 221 | let pasteboard = UIPasteboard.general |
| 222 | let context = T3ComposerClipboard.read() |
| 223 | if !context["fragment", default: ""].isEmpty || context["html", default: ""].contains("data-t3-context-fragment=") { |
| 224 | onPasteContext?(context) |
| 225 | return |
| 226 | } |
| 227 | let imageProviders = pasteboard.itemProviders.filter { |
| 228 | $0.canLoadObject(ofClass: UIImage.self) |
| 229 | } |
| 230 | if !imageProviders.isEmpty { |
| 231 | loadImages(from: imageProviders) |
| 232 | return |
| 233 | } |
| 234 | |
| 235 | let images = pasteboard.images ?? [] |
| 236 | if !images.isEmpty { |
| 237 | let urls = images.compactMap(Self.writeTemporaryImage) |
| 238 | if !urls.isEmpty { |
| 239 | onPasteImages?(urls) |
| 240 | return |
| 241 | } |
| 242 | } |
| 243 | if !bypassTextPasteInterception, |
| 244 | let text = pasteboard.string, shouldInterceptTextPaste(text) { |
| 245 | onPasteText?(text, selectedRange) |
| 246 | return |
| 247 | } |
| 248 | super.paste(sender) |
| 249 | } |
| 250 | |
| 251 | private func shouldInterceptTextPaste(_ text: String) -> Bool { |
| 252 | guard textPasteThresholdBytes > 0, !text.isEmpty else { return false } |
| 253 | let pastedLength = (text as NSString).length |
| 254 | if pastedLength >= textPasteThresholdBytes || text.utf8.count >= textPasteThresholdBytes { |
| 255 | return true |
| 256 | } |
| 257 | // Chips occupy one display character but expand to their source in the |
| 258 | // submitted message. Measure that source, including the replaced selection. |
| 259 | let sourceLength = sourceOffset(forDisplayOffset: attributedText.length) |
| 260 | let selectedLength = sourceOffset(forDisplayOffset: NSMaxRange(selectedRange)) - |
| 261 | sourceOffset(forDisplayOffset: selectedRange.location) |
| 262 | return sourceLength - selectedLength + pastedLength > maxInputChars |
| 263 | } |
| 264 | |
| 265 | override func deleteBackward() { |
| 266 | guard !isReadOnly else { |
| 267 | return |
| 268 | } |
| 269 | guard selectedRange.length == 0, selectedRange.location > 0 else { |
| 270 | super.deleteBackward() |
| 271 | return |
| 272 | } |
| 273 | |
| 274 | let previousOffset = selectedRange.location - 1 |
| 275 | if textStorage.attribute(.attachment, at: previousOffset, effectiveRange: nil) |
| 276 | is ComposerTextAttachment { |
| 277 | replaceDisplayRange(NSRange(location: previousOffset, length: 1)) |
| 278 | return |
| 279 | } |
| 280 | |
| 281 | super.deleteBackward() |
| 282 | } |
| 283 | |
| 284 | private func replaceDisplayRange(_ range: NSRange) { |
| 285 | guard let start = position(from: beginningOfDocument, offset: range.location), |
| 286 | let end = position(from: start, offset: range.length), |
| 287 | let textRange = textRange(from: start, to: end) else { |
| 288 | return |
| 289 | } |
| 290 | replace(textRange, withText: "") |
| 291 | } |
| 292 | |
| 293 | func loadImages(from providers: [NSItemProvider]) { |
| 294 | let group = DispatchGroup() |
| 295 | let lock = NSLock() |
| 296 | var images = [UIImage?](repeating: nil, count: providers.count) |
| 297 | |
| 298 | for (index, provider) in providers.enumerated() { |
| 299 | group.enter() |
| 300 | provider.loadObject(ofClass: UIImage.self) { object, _ in |
| 301 | defer { group.leave() } |
| 302 | guard let image = object as? UIImage else { |
| 303 | return |
| 304 | } |
| 305 | lock.lock() |
| 306 | images[index] = image |
| 307 | lock.unlock() |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | group.notify(queue: .main) { [weak self] in |
| 312 | guard let self, !self.isReadOnly else { |
| 313 | return |
| 314 | } |
| 315 | let urls = images.compactMap { $0 }.compactMap(Self.writeTemporaryImage) |
| 316 | if !urls.isEmpty { |
| 317 | self.onPasteImages?(urls) |
| 318 | } |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | override func copy(_ sender: Any?) { |
| 323 | guard selectedRange.length > 0 else { |
| 324 | return super.copy(sender) |
| 325 | } |
| 326 | T3ComposerClipboard.write(text: serializedText(in: selectedRange), fragment: clipboardFragment) |
| 327 | } |
| 328 | |
| 329 | override func cut(_ sender: Any?) { |
| 330 | guard !isReadOnly else { |
| 331 | return |
| 332 | } |
| 333 | guard isEditable, selectedRange.length > 0 else { |
| 334 | return super.cut(sender) |
| 335 | } |
| 336 | copy(sender) |
| 337 | textStorage.replaceCharacters(in: selectedRange, with: "") |
| 338 | selectedRange = NSRange(location: selectedRange.location, length: 0) |
| 339 | onAttributedMutation?() |
| 340 | } |
| 341 | |
| 342 | func serializedText() -> String { |
| 343 | serializedText(in: NSRange(location: 0, length: attributedText.length)) |
| 344 | } |
| 345 | |
| 346 | func serializedText(in range: NSRange) -> String { |
| 347 | guard range.length > 0 else { |
| 348 | return "" |
| 349 | } |
| 350 | |
| 351 | let source = NSMutableString() |
| 352 | let nsString = attributedText.string as NSString |
| 353 | var cursor = range.location |
| 354 | let end = NSMaxRange(range) |
| 355 | attributedText.enumerateAttribute(.attachment, in: range) { value, attachmentRange, _ in |
| 356 | if attachmentRange.location > cursor { |
| 357 | source.append( |
| 358 | nsString.substring( |
| 359 | with: NSRange(location: cursor, length: attachmentRange.location - cursor) |
| 360 | ) |
| 361 | ) |
| 362 | } |
| 363 | if let attachment = value as? ComposerTextAttachment { |
| 364 | source.append(attachment.source) |
| 365 | } else { |
| 366 | source.append(nsString.substring(with: attachmentRange)) |
| 367 | } |
| 368 | cursor = NSMaxRange(attachmentRange) |
| 369 | } |
| 370 | if cursor < end { |
| 371 | source.append(nsString.substring(with: NSRange(location: cursor, length: end - cursor))) |
| 372 | } |
| 373 | return source as String |
| 374 | } |
| 375 | |
| 376 | func sourceOffset(forDisplayOffset displayOffset: Int) -> Int { |
| 377 | let boundedOffset = max(0, min(attributedText.length, displayOffset)) |
| 378 | if boundedOffset == 0 { |
| 379 | return 0 |
| 380 | } |
| 381 | |
| 382 | var sourceOffset = 0 |
| 383 | let range = NSRange(location: 0, length: boundedOffset) |
| 384 | attributedText.enumerateAttribute(.attachment, in: range) { value, attributeRange, _ in |
| 385 | if let attachment = value as? ComposerTextAttachment { |
| 386 | sourceOffset += (attachment.source as NSString).length |
| 387 | } else { |
| 388 | sourceOffset += attributeRange.length |
| 389 | } |
| 390 | } |
| 391 | return sourceOffset |
| 392 | } |
| 393 | |
| 394 | private static func writeTemporaryImage(_ image: UIImage) -> String? { |
| 395 | guard let data = image.pngData() else { |
| 396 | return nil |
| 397 | } |
| 398 | let directory = FileManager.default.temporaryDirectory |
| 399 | .appendingPathComponent(pastedImageDirectoryName, isDirectory: true) |
| 400 | do { |
| 401 | try FileManager.default.createDirectory( |
| 402 | at: directory, |
| 403 | withIntermediateDirectories: true |
| 404 | ) |
| 405 | removeStaleTemporaryImages(in: directory) |
| 406 | let url = directory.appendingPathComponent("\(UUID().uuidString).png") |
| 407 | try data.write(to: url, options: .atomic) |
| 408 | return url.absoluteString |
| 409 | } catch { |
| 410 | return nil |
| 411 | } |
| 412 | } |
| 413 | |
| 414 | private static func removeStaleTemporaryImages(in directory: URL) { |
| 415 | let cutoff = Date().addingTimeInterval(-stalePastedImageAge) |
| 416 | guard let urls = try? FileManager.default.contentsOfDirectory( |
| 417 | at: directory, |
| 418 | includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], |
| 419 | options: [.skipsHiddenFiles] |
| 420 | ) else { |
| 421 | return |
| 422 | } |
| 423 | |
| 424 | for url in urls { |
| 425 | guard |
| 426 | let values = try? url.resourceValues( |
| 427 | forKeys: [.contentModificationDateKey, .isRegularFileKey] |
| 428 | ), |
| 429 | values.isRegularFile == true, |
| 430 | let modifiedAt = values.contentModificationDate, |
| 431 | modifiedAt < cutoff |
| 432 | else { |
| 433 | continue |
| 434 | } |
| 435 | try? FileManager.default.removeItem(at: url) |
| 436 | } |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDropDelegate, UIGestureRecognizerDelegate { |
| 441 | private let textView = ComposerTextView() |
| 442 | private let placeholderLabel = UILabel() |
| 443 | private var value = "" |
| 444 | private var tokensJson = "[]" |
| 445 | private var tokens: [ComposerTokenPayload] = [] |
| 446 | private var requestedSelection: ComposerSelectionPayload? |
| 447 | private var theme = ComposerThemePayload( |
| 448 | text: "#262626", |
| 449 | placeholder: "#8e8e93", |
| 450 | chipBackground: "#f2f2f7", |
| 451 | chipBorder: "#dedee3", |
| 452 | chipText: "#262626", |
| 453 | skillBackground: "#f9e8fb", |
| 454 | skillBorder: "#e5a6eb", |
| 455 | skillText: "#a21caf", |
| 456 | fileTint: "#737373" |
| 457 | ) |
| 458 | private var fontFamily = "DMSans-Regular" |
| 459 | private var fontSize: CGFloat = 14 |
| 460 | private var lineHeight: CGFloat = 20 |
| 461 | private var contentInsetVertical: CGFloat = 0 |
| 462 | private var shouldAutoFocus = false |
| 463 | private var didAutoFocus = false |
| 464 | private var isReadOnly = false |
| 465 | private var isApplyingControlledValue = false |
| 466 | private var nativeEventCount = 0 |
| 467 | private var lastContentSize = CGSize.zero |
| 468 | private var iconImages: [String: UIImage] = [:] |
| 469 | private var pendingIconUris = Set<String>() |
| 470 | private var tokensNeedRebuild = false |
| 471 | private var chipsNeedMeasuredWidth = false |
| 472 | |
| 473 | let onComposerChange = EventDispatcher() |
| 474 | let onComposerSelectionChange = EventDispatcher() |
| 475 | let onComposerFocus = EventDispatcher() |
| 476 | let onComposerBlur = EventDispatcher() |
| 477 | let onComposerSubmit = EventDispatcher() |
| 478 | let onComposerPasteImages = EventDispatcher() |
| 479 | let onComposerContextPress = EventDispatcher() |
| 480 | let onComposerPasteContext = EventDispatcher() |
| 481 | let onComposerPasteText = EventDispatcher() |
| 482 | let onComposerContentSizeChange = EventDispatcher() |
| 483 | |
| 484 | public required init(appContext: AppContext? = nil) { |
| 485 | super.init(appContext: appContext) |
| 486 | |
| 487 | clipsToBounds = false |
| 488 | textView.delegate = self |
| 489 | textView.textDropDelegate = self |
| 490 | textView.backgroundColor = .clear |
| 491 | textView.textContainerInset = .zero |
| 492 | textView.textContainer.lineFragmentPadding = 0 |
| 493 | textView.keyboardDismissMode = .interactive |
| 494 | textView.alwaysBounceVertical = false |
| 495 | textView.showsVerticalScrollIndicator = true |
| 496 | textView.adjustsFontForContentSizeCategory = true |
| 497 | textView.onPasteImages = { [weak self] urls in |
| 498 | self?.onComposerPasteImages(["uris": urls]) |
| 499 | } |
| 500 | textView.onPasteContext = { [weak self] context in |
| 501 | guard let self else { return } |
| 502 | let selection = self.sourceSelection() |
| 503 | self.nativeEventCount += 1 |
| 504 | var payload: [String: Any] = context |
| 505 | payload["value"] = self.textView.serializedText() |
| 506 | payload["eventCount"] = self.nativeEventCount |
| 507 | payload["selection"] = ["start": selection.start, "end": selection.end] |
| 508 | self.onComposerPasteContext(payload) |
| 509 | } |
| 510 | textView.onPasteText = { [weak self] text, _ in |
| 511 | guard let self else { return } |
| 512 | let selection = self.sourceSelection() |
| 513 | self.nativeEventCount += 1 |
| 514 | self.onComposerPasteText([ |
| 515 | "value": self.textView.serializedText(), |
| 516 | "eventCount": self.nativeEventCount, |
| 517 | "text": text, |
| 518 | "selection": ["start": selection.start, "end": selection.end], |
| 519 | ]) |
| 520 | } |
| 521 | textView.onAttributedMutation = { [weak self] in |
| 522 | self?.emitTextChange() |
| 523 | } |
| 524 | textView.onSubmit = { [weak self] alternate in |
| 525 | self?.onComposerSubmit(["alternate": alternate]) |
| 526 | } |
| 527 | let contextTap = UITapGestureRecognizer(target: self, action: #selector(openContext(_:))) |
| 528 | contextTap.cancelsTouchesInView = false |
| 529 | contextTap.delegate = self |
| 530 | textView.addGestureRecognizer(contextTap) |
| 531 | addSubview(textView) |
| 532 | |
| 533 | placeholderLabel.numberOfLines = 0 |
| 534 | placeholderLabel.adjustsFontForContentSizeCategory = true |
| 535 | addSubview(placeholderLabel) |
| 536 | applyTypography() |
| 537 | applyTheme() |
| 538 | } |
| 539 | |
| 540 | @objc private func openContext(_ recognizer: UITapGestureRecognizer) { |
| 541 | guard let (index, attachment) = contextAttachment(at: recognizer.location(in: textView)) else { |
| 542 | textView.becomeFirstResponder() |
| 543 | return |
| 544 | } |
| 545 | openContext(index: index, attachment: attachment) |
| 546 | } |
| 547 | |
| 548 | private func openContext(index: Int, attachment: ComposerTextAttachment) { |
| 549 | let start = textView.sourceOffset(forDisplayOffset: index) |
| 550 | onComposerContextPress(["source": attachment.source, "start": start, "end": start + (attachment.source as NSString).length]) |
| 551 | } |
| 552 | |
| 553 | public override var accessibilityElements: [Any]? { |
| 554 | get { |
| 555 | var elements: [Any] = [textView] |
| 556 | let layout = textView.layoutManager |
| 557 | textView.textStorage.enumerateAttribute(.attachment, in: NSRange(location: 0, length: textView.textStorage.length)) { value, range, _ in |
| 558 | guard let attachment = value as? ComposerTextAttachment else { return } |
| 559 | let glyphRange = layout.glyphRange(forCharacterRange: range, actualCharacterRange: nil) |
| 560 | let rect = layout.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer) |
| 561 | .offsetBy(dx: textView.textContainerInset.left, dy: textView.textContainerInset.top) |
| 562 | guard rect.intersects(textView.bounds) else { return } |
| 563 | let element = ComposerContextAccessibilityElement(accessibilityContainer: self) |
| 564 | element.accessibilityLabel = attachment.label |
| 565 | element.accessibilityTraits = .button |
| 566 | element.accessibilityFrameInContainerSpace = textView.convert(rect, to: self) |
| 567 | element.activate = { [weak self] in |
| 568 | guard let self, range.location < self.textView.textStorage.length, |
| 569 | self.textView.textStorage.attribute(.attachment, at: range.location, effectiveRange: nil) as? ComposerTextAttachment === attachment else { return false } |
| 570 | self.openContext(index: range.location, attachment: attachment) |
| 571 | return true |
| 572 | } |
| 573 | elements.append(element) |
| 574 | } |
| 575 | return elements |
| 576 | } |
| 577 | set { super.accessibilityElements = newValue } |
| 578 | } |
| 579 | |
| 580 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { |
| 581 | true |
| 582 | } |
| 583 | |
| 584 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { |
| 585 | true |
| 586 | } |
| 587 | |
| 588 | public func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { |
| 589 | // A chip is text context, not an image to save to the camera roll. |
| 590 | !(textAttachment is ComposerTextAttachment) |
| 591 | } |
| 592 | |
| 593 | private func contextAttachment(at point: CGPoint) -> (Int, ComposerTextAttachment)? { |
| 594 | let containerPoint = CGPoint(x: point.x - textView.textContainerInset.left, y: point.y - textView.textContainerInset.top) |
| 595 | let layout = textView.layoutManager |
| 596 | let index = layout.characterIndex(for: containerPoint, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil) |
| 597 | guard index < textView.textStorage.length, |
| 598 | let attachment = textView.textStorage.attribute(.attachment, at: index, effectiveRange: nil) as? ComposerTextAttachment else { return nil } |
| 599 | let glyphRange = layout.glyphRange(forCharacterRange: NSRange(location: index, length: 1), actualCharacterRange: nil) |
| 600 | guard layout.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer).contains(containerPoint) else { return nil } |
| 601 | return (index, attachment) |
| 602 | } |
| 603 | |
| 604 | public override func layoutSubviews() { |
| 605 | super.layoutSubviews() |
| 606 | textView.frame = bounds |
| 607 | if chipsNeedMeasuredWidth, bounds.width > 0 { |
| 608 | chipsNeedMeasuredWidth = false |
| 609 | applyControlledDocument(force: true) |
| 610 | } |
| 611 | let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding |
| 612 | let placeholderY = textView.textContainerInset.top |
| 613 | let placeholderWidth = max( |
| 614 | 0, |
| 615 | bounds.width - placeholderX - textView.textContainerInset.right - |
| 616 | textView.textContainer.lineFragmentPadding |
| 617 | ) |
| 618 | placeholderLabel.frame = CGRect( |
| 619 | x: placeholderX, |
| 620 | y: placeholderY, |
| 621 | width: placeholderWidth, |
| 622 | height: max(lineHeight, placeholderLabel.font.lineHeight) |
| 623 | ) |
| 624 | emitContentSizeIfNeeded() |
| 625 | } |
| 626 | |
| 627 | func setClipboardFragment(_ fragment: String) { |
| 628 | textView.clipboardFragment = fragment |
| 629 | } |
| 630 | |
| 631 | public override func didMoveToWindow() { |
| 632 | super.didMoveToWindow() |
| 633 | guard window != nil, shouldAutoFocus, !didAutoFocus else { |
| 634 | return |
| 635 | } |
| 636 | didAutoFocus = true |
| 637 | DispatchQueue.main.async { [weak self] in |
| 638 | self?.textView.becomeFirstResponder() |
| 639 | } |
| 640 | } |
| 641 | |
| 642 | func setControlledDocumentJson(_ documentJson: String) { |
| 643 | guard let document = decode(ComposerControlledDocumentPayload.self, from: documentJson), |
| 644 | document.mostRecentEventCount >= nativeEventCount else { |
| 645 | return |
| 646 | } |
| 647 | if document.isNativeEcho && textView.serializedText() != document.value { |
| 648 | return |
| 649 | } |
| 650 | if tokensJson != document.tokensJson { |
| 651 | tokensJson = document.tokensJson |
| 652 | tokens = decode([ComposerTokenPayload].self, from: document.tokensJson) ?? [] |
| 653 | tokensNeedRebuild = true |
| 654 | } |
| 655 | value = document.value |
| 656 | requestedSelection = document.selection |
| 657 | applyControlledDocument(force: tokensNeedRebuild) |
| 658 | applyRequestedSelection() |
| 659 | if tokensMatchCurrentValue() { |
| 660 | tokensNeedRebuild = false |
| 661 | } |
| 662 | } |
| 663 | |
| 664 | func setThemeJson(_ themeJson: String) { |
| 665 | guard let nextTheme = decode(ComposerThemePayload.self, from: themeJson) else { |
| 666 | return |
| 667 | } |
| 668 | theme = nextTheme |
| 669 | applyTheme() |
| 670 | applyControlledDocument(force: true) |
| 671 | } |
| 672 | |
| 673 | func setPlaceholder(_ placeholder: String) { |
| 674 | placeholderLabel.text = placeholder |
| 675 | setNeedsLayout() |
| 676 | } |
| 677 | |
| 678 | func setFontFamily(_ fontFamily: String) { |
| 679 | self.fontFamily = fontFamily |
| 680 | applyTypography() |
| 681 | applyControlledDocument(force: true) |
| 682 | } |
| 683 | |
| 684 | func setFontSize(_ fontSize: CGFloat) { |
| 685 | self.fontSize = fontSize |
| 686 | applyTypography() |
| 687 | applyControlledDocument(force: true) |
| 688 | } |
| 689 | |
| 690 | func setLineHeight(_ lineHeight: CGFloat) { |
| 691 | self.lineHeight = lineHeight |
| 692 | applyTypography() |
| 693 | applyControlledDocument(force: true) |
| 694 | } |
| 695 | |
| 696 | func setContentInsetVertical(_ contentInsetVertical: CGFloat) { |
| 697 | self.contentInsetVertical = contentInsetVertical |
| 698 | textView.textContainerInset = UIEdgeInsets( |
| 699 | top: contentInsetVertical, |
| 700 | left: 0, |
| 701 | bottom: contentInsetVertical, |
| 702 | right: 0 |
| 703 | ) |
| 704 | setNeedsLayout() |
| 705 | } |
| 706 | |
| 707 | func setEditable(_ editable: Bool) { |
| 708 | textView.isEditable = editable |
| 709 | } |
| 710 | |
| 711 | func setReadOnly(_ readOnly: Bool) { |
| 712 | isReadOnly = readOnly |
| 713 | textView.isReadOnly = readOnly |
| 714 | } |
| 715 | |
| 716 | func setScrollEnabled(_ scrollEnabled: Bool) { |
| 717 | textView.isScrollEnabled = scrollEnabled |
| 718 | } |
| 719 | |
| 720 | func setAutoFocus(_ autoFocus: Bool) { |
| 721 | shouldAutoFocus = autoFocus |
| 722 | } |
| 723 | |
| 724 | func setAutoCorrect(_ autoCorrect: Bool) { |
| 725 | textView.autocorrectionType = autoCorrect ? .yes : .no |
| 726 | } |
| 727 | |
| 728 | func setSpellCheck(_ spellCheck: Bool) { |
| 729 | textView.spellCheckingType = spellCheck ? .yes : .no |
| 730 | } |
| 731 | |
| 732 | func setEnterBehavior(_ behavior: String) { |
| 733 | textView.enterBehavior = ComposerEnterBehavior(rawValue: behavior) ?? .send |
| 734 | } |
| 735 | |
| 736 | func setSubmitTitle(_ title: String) { |
| 737 | textView.submitTitle = title |
| 738 | } |
| 739 | |
| 740 | func setAlternateSubmitTitle(_ title: String) { |
| 741 | textView.alternateSubmitTitle = title |
| 742 | } |
| 743 | |
| 744 | func setTextPasteThresholdBytes(_ threshold: Int) { |
| 745 | textView.textPasteThresholdBytes = threshold |
| 746 | } |
| 747 | |
| 748 | func setMaxInputChars(_ maxInputChars: Int) { |
| 749 | textView.maxInputChars = maxInputChars |
| 750 | } |
| 751 | |
| 752 | func focusEditor() { |
| 753 | textView.becomeFirstResponder() |
| 754 | } |
| 755 | |
| 756 | func blurEditor() { |
| 757 | textView.resignFirstResponder() |
| 758 | } |
| 759 | |
| 760 | func setSelection(start: Int, end: Int) { |
| 761 | requestedSelection = ComposerSelectionPayload(start: start, end: end) |
| 762 | applyRequestedSelection() |
| 763 | } |
| 764 | |
| 765 | public func textViewDidChange(_ textView: UITextView) { |
| 766 | emitTextChange() |
| 767 | } |
| 768 | |
| 769 | public func textViewDidChangeSelection(_ textView: UITextView) { |
| 770 | guard !isApplyingControlledValue else { |
| 771 | return |
| 772 | } |
| 773 | restoreBaseTypingAttributes() |
| 774 | // UIKit moves the selection before textViewDidChange runs. Emitting here |
| 775 | // would pair the post-edit text with a pre-edit revision counter, so let |
| 776 | // the change event that follows carry both; only pure caret moves emit. |
| 777 | guard self.textView.serializedText() == value else { |
| 778 | return |
| 779 | } |
| 780 | emitSelection() |
| 781 | } |
| 782 | |
| 783 | public func textView( |
| 784 | _ textView: UITextView, |
| 785 | shouldChangeTextIn range: NSRange, |
| 786 | replacementText text: String |
| 787 | ) -> Bool { |
| 788 | restoreBaseTypingAttributes() |
| 789 | return !isReadOnly |
| 790 | } |
| 791 | |
| 792 | public func textDroppableView( |
| 793 | _ textDroppableView: UIView & UITextDroppable, |
| 794 | proposalForDrop drop: UITextDropRequest |
| 795 | ) -> UITextDropProposal { |
| 796 | guard !isReadOnly else { |
| 797 | return UITextDropProposal(operation: .cancel) |
| 798 | } |
| 799 | guard droppedImageProviders(in: drop) != nil else { |
| 800 | return drop.suggestedProposal |
| 801 | } |
| 802 | |
| 803 | // The composer owns image drops so UIKit does not insert NSTextAttachments |
| 804 | // that the controlled plain-text value cannot represent. |
| 805 | let proposal = UITextDropProposal(operation: .copy) |
| 806 | proposal.dropAction = .insert |
| 807 | proposal.dropPerformer = .delegate |
| 808 | return proposal |
| 809 | } |
| 810 | |
| 811 | public func textDroppableView( |
| 812 | _ textDroppableView: UIView & UITextDroppable, |
| 813 | willPerformDrop drop: UITextDropRequest |
| 814 | ) { |
| 815 | guard !isReadOnly else { |
| 816 | return |
| 817 | } |
| 818 | guard let imageProviders = droppedImageProviders(in: drop) else { |
| 819 | return |
| 820 | } |
| 821 | textView.loadImages(from: imageProviders) |
| 822 | } |
| 823 | |
| 824 | private func droppedImageProviders(in drop: UITextDropRequest) -> [NSItemProvider]? { |
| 825 | let providers = drop.dropSession.items.map(\.itemProvider) |
| 826 | guard !providers.isEmpty, |
| 827 | providers.allSatisfy({ $0.canLoadObject(ofClass: UIImage.self) }) else { |
| 828 | return nil |
| 829 | } |
| 830 | return providers |
| 831 | } |
| 832 | |
| 833 | public func textViewDidBeginEditing(_ textView: UITextView) { |
| 834 | onComposerFocus() |
| 835 | } |
| 836 | |
| 837 | public func textViewDidEndEditing(_ textView: UITextView) { |
| 838 | onComposerBlur() |
| 839 | } |
| 840 | |
| 841 | private func applyControlledDocument(force: Bool = false) { |
| 842 | let currentSource = textView.serializedText() |
| 843 | guard force || currentSource != value || !documentMatchesExpectedTokens() else { |
| 844 | updatePlaceholderVisibility() |
| 845 | return |
| 846 | } |
| 847 | |
| 848 | let previousSelection = sourceSelection() |
| 849 | isApplyingControlledValue = true |
| 850 | textView.attributedText = makeAttributedDocument() |
| 851 | let targetSelection = requestedSelection ?? previousSelection |
| 852 | requestedSelection = nil |
| 853 | textView.selectedRange = displayRange(for: targetSelection) |
| 854 | restoreBaseTypingAttributes() |
| 855 | isApplyingControlledValue = false |
| 856 | updatePlaceholderVisibility() |
| 857 | emitContentSizeIfNeeded() |
| 858 | } |
| 859 | |
| 860 | private func makeAttributedDocument() -> NSAttributedString { |
| 861 | let result = NSMutableAttributedString() |
| 862 | let source = value as NSString |
| 863 | var cursor = 0 |
| 864 | let validTokens = tokens.filter { |
| 865 | $0.start >= cursor && |
| 866 | $0.end > $0.start && |
| 867 | $0.end <= source.length && |
| 868 | source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source |
| 869 | } |
| 870 | |
| 871 | for token in validTokens { |
| 872 | if token.start < cursor { |
| 873 | continue |
| 874 | } |
| 875 | if token.start > cursor { |
| 876 | appendPlainText( |
| 877 | source.substring(with: NSRange(location: cursor, length: token.start - cursor)), |
| 878 | to: result |
| 879 | ) |
| 880 | } |
| 881 | result.append(makeAttachmentString(token)) |
| 882 | cursor = token.end |
| 883 | } |
| 884 | if cursor < source.length { |
| 885 | appendPlainText( |
| 886 | source.substring(with: NSRange(location: cursor, length: source.length - cursor)), |
| 887 | to: result |
| 888 | ) |
| 889 | } |
| 890 | return result |
| 891 | } |
| 892 | |
| 893 | private func appendPlainText(_ text: String, to result: NSMutableAttributedString) { |
| 894 | result.append(NSAttributedString(string: text, attributes: baseAttributes())) |
| 895 | } |
| 896 | |
| 897 | private func makeAttachmentString(_ token: ComposerTokenPayload) -> NSAttributedString { |
| 898 | let isSkill = token.type == "skill" |
| 899 | let accent = token.accent.flatMap { UIColor(composerHex: $0) } |
| 900 | let foreground = UIColor(composerHex: theme.chipText) ?? .label |
| 901 | let border = UIColor(composerHex: theme.chipBorder) ?? .separator |
| 902 | let tint = accent.map { blend($0, over: foreground, weight: 0.22) } |
| 903 | ?? UIColor(composerHex: isSkill ? theme.skillText : theme.fileTint) ?? .secondaryLabel |
| 904 | let iconName = token.symbol ?? (isSkill ? "cube" : "doc") |
| 905 | let iconImage = token.iconUri.flatMap(iconImage(for:)) |
| 906 | let style = ComposerChipStyle( |
| 907 | tint: tint, |
| 908 | backgroundColor: accent?.withAlphaComponent(0.11) ?? UIColor( |
| 909 | composerHex: isSkill ? theme.skillBackground : theme.chipBackground |
| 910 | ) ?? .secondarySystemFill, |
| 911 | borderColor: accent.map { blend($0, over: border, weight: 0.34) } ?? UIColor( |
| 912 | composerHex: isSkill ? theme.skillBorder : theme.chipBorder |
| 913 | ) ?? .separator, |
| 914 | textColor: tint |
| 915 | ) |
| 916 | let image = renderChip( |
| 917 | label: token.label, |
| 918 | detail: token.detail, |
| 919 | iconName: iconName, |
| 920 | iconImage: iconImage, |
| 921 | style: style |
| 922 | ) |
| 923 | let font = UIFont(name: fontFamily, size: fontSize) |
| 924 | ?? UIFont.systemFont(ofSize: fontSize) |
| 925 | let baselineOffset = floor((font.capHeight - image.size.height) / 2) |
| 926 | let attachment = ComposerTextAttachment( |
| 927 | source: token.source, |
| 928 | label: token.label, |
| 929 | image: image, |
| 930 | size: image.size, |
| 931 | baselineOffset: baselineOffset |
| 932 | ) |
| 933 | let attributedAttachment = NSMutableAttributedString(attachment: attachment) |
| 934 | attributedAttachment.addAttributes( |
| 935 | baseAttributes(), |
| 936 | range: NSRange(location: 0, length: attributedAttachment.length) |
| 937 | ) |
| 938 | return attributedAttachment |
| 939 | } |
| 940 | |
| 941 | /// Chip glyphs with no SF Symbol that reads correctly. A pull request would otherwise land on |
| 942 | /// `arrow.triangle.branch`, a road-sign fork that says "branch", not "pull request", so it is |
| 943 | /// drawn from the same lucide geometry web and Android use. |
| 944 | private static func vectorIcon(named name: String, size: CGFloat, color: UIColor) -> UIImage? { |
| 945 | guard name == "git-pull-request" else { return nil } |
| 946 | return UIGraphicsImageRenderer(size: CGSize(width: size, height: size)).image { _ in |
| 947 | let s = size / 24 // lucide authors on a 24pt grid. |
| 948 | let path = UIBezierPath() |
| 949 | for centre in [CGPoint(x: 18 * s, y: 18 * s), CGPoint(x: 6 * s, y: 6 * s)] { |
| 950 | path.append(UIBezierPath(arcCenter: centre, radius: 3 * s, startAngle: 0, |
| 951 | endAngle: .pi * 2, clockwise: true)) |
| 952 | } |
| 953 | path.move(to: CGPoint(x: 13 * s, y: 6 * s)) |
| 954 | path.addLine(to: CGPoint(x: 16 * s, y: 6 * s)) |
| 955 | path.addCurve(to: CGPoint(x: 18 * s, y: 8 * s), |
| 956 | controlPoint1: CGPoint(x: 17.1 * s, y: 6 * s), |
| 957 | controlPoint2: CGPoint(x: 18 * s, y: 6.9 * s)) |
| 958 | path.addLine(to: CGPoint(x: 18 * s, y: 15 * s)) |
| 959 | path.move(to: CGPoint(x: 6 * s, y: 9 * s)) |
| 960 | path.addLine(to: CGPoint(x: 6 * s, y: 21 * s)) |
| 961 | path.lineWidth = 2 * s |
| 962 | path.lineCapStyle = .round |
| 963 | path.lineJoinStyle = .round |
| 964 | color.setStroke() |
| 965 | path.stroke() |
| 966 | } |
| 967 | } |
| 968 | |
| 969 | private func renderChip( |
| 970 | label: String, |
| 971 | detail: String?, |
| 972 | iconName: String, |
| 973 | iconImage: UIImage?, |
| 974 | style: ComposerChipStyle |
| 975 | ) -> UIImage { |
| 976 | // Kept in step with `T3ContextChipVectorIcon` in the markdown module: a chip drawn here and |
| 977 | // the same chip drawn in a sent message have to be the same picture. |
| 978 | let chipFontSize = fontSize * 0.86 |
| 979 | let font = UIFont(name: "DMSans-Medium", size: chipFontSize) |
| 980 | ?? UIFont.systemFont(ofSize: chipFontSize, weight: .medium) |
| 981 | let fallbackIcon = Self.vectorIcon(named: iconName, size: 14, color: style.textColor) |
| 982 | ?? UIImage( |
| 983 | systemName: iconName, |
| 984 | withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium) |
| 985 | ) |
| 986 | let icon = iconImage ?? fallbackIcon |
| 987 | // The size reads as metadata, not part of the name, so it renders a step down from the |
| 988 | // label the way the web chip does. |
| 989 | let paragraph = NSMutableParagraphStyle() |
| 990 | paragraph.alignment = .left |
| 991 | let detailFont = UIFont(name: "DMSans-Medium", size: chipFontSize * 0.84) |
| 992 | ?? UIFont.systemFont(ofSize: chipFontSize * 0.84, weight: .medium) |
| 993 | let attributedLabel = NSMutableAttributedString( |
| 994 | string: label, |
| 995 | attributes: [.font: font, .foregroundColor: style.textColor, .paragraphStyle: paragraph] |
| 996 | ) |
| 997 | if let detail, !detail.isEmpty { |
| 998 | attributedLabel.append( |
| 999 | NSAttributedString( |
| 1000 | string: " \(detail)", |
| 1001 | attributes: [ |
| 1002 | .font: detailFont, |
| 1003 | .foregroundColor: style.textColor, |
| 1004 | .paragraphStyle: paragraph, |
| 1005 | ] |
| 1006 | ) |
| 1007 | ) |
| 1008 | } |
| 1009 | let iconWidth: CGFloat = icon == nil ? 0 : chipFontSize * 1.17 |
| 1010 | let iconGap: CGFloat = icon == nil ? 0 : chipFontSize * 0.33 |
| 1011 | let padding = chipFontSize * 0.5 |
| 1012 | let height = ceil(chipFontSize * 1.41) |
| 1013 | // A long path would otherwise draw a chip wider than the composer and clip. Cap the label |
| 1014 | // to the text the editor can actually show and truncate inside it, as Android's |
| 1015 | // `maximumWidth` does, so the chip always fits the line it sits on. |
| 1016 | let availableWidth = textView.textContainer.size.width > 0 |
| 1017 | ? textView.textContainer.size.width - textView.textContainer.lineFragmentPadding * 2 |
| 1018 | : (textView.window?.bounds.width ?? bounds.width) |
| 1019 | if availableWidth <= 0 { |
| 1020 | chipsNeedMeasuredWidth = true |
| 1021 | } |
| 1022 | let maximumLabelWidth = max(chipFontSize * 3, availableWidth - padding * 2 - iconWidth - iconGap) |
| 1023 | paragraph.lineBreakMode = .byTruncatingMiddle |
| 1024 | attributedLabel.addAttribute( |
| 1025 | .paragraphStyle, |
| 1026 | value: paragraph, |
| 1027 | range: NSRange(location: 0, length: attributedLabel.length) |
| 1028 | ) |
| 1029 | let measured = attributedLabel.size() |
| 1030 | let textSize = CGSize(width: min(measured.width, maximumLabelWidth), height: measured.height) |
| 1031 | let width = ceil(padding * 2 + iconWidth + iconGap + textSize.width) |
| 1032 | let format = UIGraphicsImageRendererFormat.preferred() |
| 1033 | format.opaque = false |
| 1034 | let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format) |
| 1035 | return renderer.image { context in |
| 1036 | let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height)) |
| 1037 | let path = UIBezierPath(roundedRect: rect.insetBy(dx: 0.5, dy: 0.5), cornerRadius: chipFontSize * 0.5) |
| 1038 | style.backgroundColor.setFill() |
| 1039 | path.fill() |
| 1040 | style.borderColor.setStroke() |
| 1041 | path.lineWidth = 1 |
| 1042 | path.stroke() |
| 1043 | |
| 1044 | var x = padding |
| 1045 | if let icon { |
| 1046 | let renderedIcon = iconImage == nil |
| 1047 | ? icon.withTintColor(style.tint, renderingMode: .alwaysOriginal) |
| 1048 | : icon |
| 1049 | renderedIcon.draw( |
| 1050 | in: CGRect(x: x, y: (height - iconWidth) / 2, width: iconWidth, height: iconWidth) |
| 1051 | ) |
| 1052 | x += iconWidth + iconGap |
| 1053 | } |
| 1054 | // Exactly the measured width: a spare pixel here would let a capped label draw past |
| 1055 | // the cap instead of truncating inside it. |
| 1056 | attributedLabel.draw( |
| 1057 | in: CGRect(x: x, y: (height - textSize.height) / 2, width: textSize.width, height: textSize.height) |
| 1058 | ) |
| 1059 | context.cgContext.setAllowsAntialiasing(true) |
| 1060 | } |
| 1061 | } |
| 1062 | |
| 1063 | private func blend(_ accent: UIColor, over base: UIColor, weight: CGFloat) -> UIColor { |
| 1064 | var ar: CGFloat = 0, ag: CGFloat = 0, ab: CGFloat = 0, aa: CGFloat = 0 |
| 1065 | var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0 |
| 1066 | accent.getRed(&ar, green: &ag, blue: &ab, alpha: &aa) |
| 1067 | base.getRed(&br, green: &bg, blue: &bb, alpha: &ba) |
| 1068 | return UIColor( |
| 1069 | red: ar * weight + br * (1 - weight), |
| 1070 | green: ag * weight + bg * (1 - weight), |
| 1071 | blue: ab * weight + bb * (1 - weight), |
| 1072 | alpha: aa * weight + ba * (1 - weight) |
| 1073 | ) |
| 1074 | } |
| 1075 | |
| 1076 | private func iconImage(for uri: String) -> UIImage? { |
| 1077 | if let image = iconImages[uri] { |
| 1078 | return image |
| 1079 | } |
| 1080 | guard !pendingIconUris.contains(uri), let url = URL(string: uri) else { |
| 1081 | return nil |
| 1082 | } |
| 1083 | |
| 1084 | if url.isFileURL, let image = UIImage(contentsOfFile: url.path) { |
| 1085 | iconImages[uri] = image |
| 1086 | return image |
| 1087 | } |
| 1088 | |
| 1089 | pendingIconUris.insert(uri) |
| 1090 | URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in |
| 1091 | guard let self, let data, let image = UIImage(data: data) else { |
| 1092 | DispatchQueue.main.async { |
| 1093 | self?.pendingIconUris.remove(uri) |
| 1094 | } |
| 1095 | return |
| 1096 | } |
| 1097 | DispatchQueue.main.async { |
| 1098 | self.pendingIconUris.remove(uri) |
| 1099 | self.iconImages[uri] = image |
| 1100 | self.applyControlledDocument(force: true) |
| 1101 | } |
| 1102 | }.resume() |
| 1103 | return nil |
| 1104 | } |
| 1105 | |
| 1106 | private func baseAttributes() -> [NSAttributedString.Key: Any] { |
| 1107 | let font = UIFont(name: fontFamily, size: fontSize) |
| 1108 | ?? UIFont.systemFont(ofSize: fontSize) |
| 1109 | let paragraph = NSMutableParagraphStyle() |
| 1110 | paragraph.minimumLineHeight = lineHeight |
| 1111 | paragraph.maximumLineHeight = lineHeight |
| 1112 | return [ |
| 1113 | .font: font, |
| 1114 | .foregroundColor: UIColor(composerHex: theme.text) ?? .label, |
| 1115 | .paragraphStyle: paragraph, |
| 1116 | ] |
| 1117 | } |
| 1118 | |
| 1119 | private func applyTypography() { |
| 1120 | let font = UIFont(name: fontFamily, size: fontSize) |
| 1121 | ?? UIFont.systemFont(ofSize: fontSize) |
| 1122 | textView.font = font |
| 1123 | restoreBaseTypingAttributes() |
| 1124 | placeholderLabel.font = font |
| 1125 | setNeedsLayout() |
| 1126 | } |
| 1127 | |
| 1128 | private func restoreBaseTypingAttributes() { |
| 1129 | guard textView.markedTextRange == nil else { |
| 1130 | return |
| 1131 | } |
| 1132 | textView.typingAttributes = baseAttributes() |
| 1133 | } |
| 1134 | |
| 1135 | private func applyTheme() { |
| 1136 | textView.textColor = UIColor(composerHex: theme.text) ?? .label |
| 1137 | placeholderLabel.textColor = UIColor(composerHex: theme.placeholder) ?? .placeholderText |
| 1138 | tintColor = UIColor.systemBlue |
| 1139 | } |
| 1140 | |
| 1141 | private func emitTextChange() { |
| 1142 | guard !isApplyingControlledValue else { |
| 1143 | return |
| 1144 | } |
| 1145 | value = textView.serializedText() |
| 1146 | let selection = sourceSelection() |
| 1147 | nativeEventCount += 1 |
| 1148 | onComposerChange([ |
| 1149 | "value": value, |
| 1150 | "selection": ["start": selection.start, "end": selection.end], |
| 1151 | "eventCount": nativeEventCount, |
| 1152 | ]) |
| 1153 | updatePlaceholderVisibility() |
| 1154 | emitContentSizeIfNeeded() |
| 1155 | } |
| 1156 | |
| 1157 | private func emitSelection() { |
| 1158 | // Caret moves advance the revision counter like text edits do: a |
| 1159 | // controlled payload computed before this move is stale and must fail the |
| 1160 | // revision guard instead of yanking the caret back mid-typing. |
| 1161 | let currentValue = textView.serializedText() |
| 1162 | let selection = sourceSelection() |
| 1163 | nativeEventCount += 1 |
| 1164 | onComposerSelectionChange([ |
| 1165 | "value": currentValue, |
| 1166 | "selection": ["start": selection.start, "end": selection.end], |
| 1167 | "eventCount": nativeEventCount, |
| 1168 | ]) |
| 1169 | } |
| 1170 | |
| 1171 | private func sourceSelection() -> ComposerSelectionPayload { |
| 1172 | ComposerSelectionPayload( |
| 1173 | start: textView.sourceOffset(forDisplayOffset: textView.selectedRange.location), |
| 1174 | end: textView.sourceOffset(forDisplayOffset: NSMaxRange(textView.selectedRange)) |
| 1175 | ) |
| 1176 | } |
| 1177 | |
| 1178 | private func displayRange(for selection: ComposerSelectionPayload) -> NSRange { |
| 1179 | let start = displayOffset(forSourceOffset: selection.start) |
| 1180 | let end = displayOffset(forSourceOffset: selection.end) |
| 1181 | return NSRange(location: start, length: max(0, end - start)) |
| 1182 | } |
| 1183 | |
| 1184 | private func displayOffset(forSourceOffset sourceOffset: Int) -> Int { |
| 1185 | let boundedOffset = max(0, min((value as NSString).length, sourceOffset)) |
| 1186 | var collapsedLength = 0 |
| 1187 | for token in tokens where token.end <= boundedOffset { |
| 1188 | collapsedLength += max(0, token.end - token.start - 1) |
| 1189 | } |
| 1190 | if let token = tokens.first(where: { $0.start < boundedOffset && boundedOffset < $0.end }) { |
| 1191 | return token.start - collapsedLength + 1 |
| 1192 | } |
| 1193 | return boundedOffset - collapsedLength |
| 1194 | } |
| 1195 | |
| 1196 | private func applyRequestedSelection() { |
| 1197 | guard let requestedSelection else { |
| 1198 | return |
| 1199 | } |
| 1200 | let nextRange = displayRange(for: requestedSelection) |
| 1201 | guard nextRange.location <= textView.attributedText.length, |
| 1202 | NSMaxRange(nextRange) <= textView.attributedText.length else { |
| 1203 | return |
| 1204 | } |
| 1205 | self.requestedSelection = nil |
| 1206 | // Programmatically assigning selectedRange resets the keyboard's |
| 1207 | // autocorrect and predictive-text context even when the range is |
| 1208 | // unchanged, so a no-op assignment must be skipped. |
| 1209 | guard !NSEqualRanges(nextRange, textView.selectedRange) else { |
| 1210 | return |
| 1211 | } |
| 1212 | isApplyingControlledValue = true |
| 1213 | textView.selectedRange = nextRange |
| 1214 | isApplyingControlledValue = false |
| 1215 | } |
| 1216 | |
| 1217 | private func updatePlaceholderVisibility() { |
| 1218 | placeholderLabel.isHidden = !value.isEmpty |
| 1219 | } |
| 1220 | |
| 1221 | private func emitContentSizeIfNeeded() { |
| 1222 | let nextSize = textView.contentSize |
| 1223 | guard abs(nextSize.width - lastContentSize.width) > 0.5 || |
| 1224 | abs(nextSize.height - lastContentSize.height) > 0.5 else { |
| 1225 | return |
| 1226 | } |
| 1227 | lastContentSize = nextSize |
| 1228 | onComposerContentSizeChange(["width": nextSize.width, "height": nextSize.height]) |
| 1229 | } |
| 1230 | |
| 1231 | private func decode<T: Decodable>(_ type: T.Type, from json: String) -> T? { |
| 1232 | guard let data = json.data(using: .utf8) else { |
| 1233 | return nil |
| 1234 | } |
| 1235 | return try? JSONDecoder().decode(type, from: data) |
| 1236 | } |
| 1237 | |
| 1238 | private func tokensMatchCurrentValue() -> Bool { |
| 1239 | let source = value as NSString |
| 1240 | return tokens.allSatisfy { |
| 1241 | $0.start >= 0 && |
| 1242 | $0.end > $0.start && |
| 1243 | $0.end <= source.length && |
| 1244 | source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source |
| 1245 | } |
| 1246 | } |
| 1247 | |
| 1248 | private func documentMatchesExpectedTokens() -> Bool { |
| 1249 | let source = value as NSString |
| 1250 | let expectedSources = tokens.compactMap { token -> String? in |
| 1251 | guard token.start >= 0, |
| 1252 | token.end > token.start, |
| 1253 | token.end <= source.length, |
| 1254 | source.substring( |
| 1255 | with: NSRange(location: token.start, length: token.end - token.start) |
| 1256 | ) == token.source else { |
| 1257 | return nil |
| 1258 | } |
| 1259 | return token.source |
| 1260 | } |
| 1261 | var renderedSources: [String] = [] |
| 1262 | textView.attributedText.enumerateAttribute( |
| 1263 | .attachment, |
| 1264 | in: NSRange(location: 0, length: textView.attributedText.length) |
| 1265 | ) { value, _, _ in |
| 1266 | if let attachment = value as? ComposerTextAttachment { |
| 1267 | renderedSources.append(attachment.source) |
| 1268 | } |
| 1269 | } |
| 1270 | return renderedSources == expectedSources |
| 1271 | } |
| 1272 | } |
| 1273 | |
| 1274 | private extension UIColor { |
| 1275 | convenience init?(composerHex hex: String?) { |
| 1276 | guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { |
| 1277 | return nil |
| 1278 | } |
| 1279 | if value.hasPrefix("#") { |
| 1280 | value.removeFirst() |
| 1281 | } |
| 1282 | guard value.count == 6 || value.count == 8, |
| 1283 | let raw = UInt64(value, radix: 16) else { |
| 1284 | return nil |
| 1285 | } |
| 1286 | if value.count == 8 { |
| 1287 | self.init( |
| 1288 | red: CGFloat((raw >> 24) & 0xff) / 255, |
| 1289 | green: CGFloat((raw >> 16) & 0xff) / 255, |
| 1290 | blue: CGFloat((raw >> 8) & 0xff) / 255, |
| 1291 | alpha: CGFloat(raw & 0xff) / 255 |
| 1292 | ) |
| 1293 | } else { |
| 1294 | self.init( |
| 1295 | red: CGFloat((raw >> 16) & 0xff) / 255, |
| 1296 | green: CGFloat((raw >> 8) & 0xff) / 255, |
| 1297 | blue: CGFloat(raw & 0xff) / 255, |
| 1298 | alpha: 1 |
| 1299 | ) |
| 1300 | } |
| 1301 | } |
| 1302 | } |