Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

1030 lines
56 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TextFormat
import InvisibleInkDustNode
import EmojiTextAttachmentView
import RichTextEditorCore
import RichTextEditorUIKit
/// Host theme colors for the rich-text composer backend, passed across the seam as plain `UIColor`s so
/// `ChatInputTextNode` need not depend on the editor package. The RichTextEditor backend maps these 1:1
/// into its `RichTextEditorTheme`; the legacy backend ignores them (it themes via `inputTheme`).
public struct ChatRichTextThemeColors {
public var primaryText: UIColor
public var secondaryText: UIColor
public var placeholder: UIColor
public var accent: UIColor
public var tableBorder: UIColor
public var tableHeaderBackground: UIColor
/// Checklist checkbox palette (the standard app checkbox `list.itemCheckColors`): fill = box background,
/// foreground = checkmark/stroke, border = box border. The native backend maps these into the `CheckNode`
/// it hosts as a checklist marker; the legacy backend ignores them (it has no checklist UI).
public var listCheckFillColor: UIColor
public var listCheckForegroundColor: UIColor
public var listCheckBorderColor: UIColor
/// Quote AUTHOR (attribution) line text color — dedicated (defaults to `secondaryText` at the mapping
/// site so there is no visual change until the host picks a distinct value). See `RichTextEditorTheme.quoteAuthorText`.
public var quoteAuthorText: UIColor
/// Quote AUTHOR (attribution) line placeholder color — dedicated (defaults to `placeholder` at the
/// mapping site). See `RichTextEditorTheme.quoteAuthorPlaceholder`.
public var quoteAuthorPlaceholder: UIColor
/// Passthrough
public var shadowCursor: UIColor
public init(primaryText: UIColor, secondaryText: UIColor, placeholder: UIColor, accent: UIColor, tableBorder: UIColor, tableHeaderBackground: UIColor, listCheckFillColor: UIColor, listCheckForegroundColor: UIColor, listCheckBorderColor: UIColor, quoteAuthorText: UIColor, quoteAuthorPlaceholder: UIColor, shadowCursor: UIColor) {
self.primaryText = primaryText
self.secondaryText = secondaryText
self.placeholder = placeholder
self.accent = accent
self.tableBorder = tableBorder
self.tableHeaderBackground = tableHeaderBackground
self.listCheckFillColor = listCheckFillColor
self.listCheckForegroundColor = listCheckForegroundColor
self.listCheckBorderColor = listCheckBorderColor
self.quoteAuthorText = quoteAuthorText
self.quoteAuthorPlaceholder = quoteAuthorPlaceholder
self.shadowCursor = shadowCursor
}
}
/// Format commands routable from the new editor backend's context menu. `Link` is intentionally absent —
/// it is handled host-side via `openLinkEditing` (it needs a URL-entry UI). `code`/`date` have no native
/// editor representation yet and are no-ops on the new backend.
public enum ChatRichTextFormatAction {
case bold, italic, monospace, strikethrough, underline, spoiler, quote, pullQuote, code, date
}
/// The owner-facing media-control descriptor: the resolved `EngineMedia` ("what we're dealing with") plus
/// occurrence-bound operations ("how to act on it") and the anchor. Built by the native rich-text node from
/// the editor's account-free `MediaControlRequest` (resolving `EngineMedia` from its media store) and handed
/// to the panel, which builds its own owner-specific menu. `replace`/`addMore` are reserved (nil until built).
@available(iOS 13.0, *)
public struct MediaControlContext {
public let media: EngineMedia
public let control: RichTextMediaControlKind
public let sourceView: UIView?
public let sourceRect: CGRect
public let delete: () -> Void
public let isSpoiler: Bool
public let toggleSpoiler: () -> Void
public let replace: ((String, CGSize, MediaKind) -> Void)?
public let addMore: ((String, CGSize, MediaKind) -> Void)?
public init(media: EngineMedia, control: RichTextMediaControlKind, sourceView: UIView?, sourceRect: CGRect,
delete: @escaping () -> Void,
isSpoiler: Bool,
toggleSpoiler: @escaping () -> Void,
replace: ((String, CGSize, MediaKind) -> Void)?,
addMore: ((String, CGSize, MediaKind) -> Void)?) {
self.media = media
self.control = control
self.sourceView = sourceView
self.sourceRect = sourceRect
self.delete = delete
self.isSpoiler = isSpoiler
self.toggleSpoiler = toggleSpoiler
self.replace = replace
self.addMore = addMore
}
}
/// Protocol seam for the chat composer's text editor. Implemented today by
/// `ChatRichTextInputNodeImpl` (which composes the existing `ChatInputTextNode`);
/// intended to be implemented directly by the TextKit-2 RichTextEditor later.
public protocol ChatRichTextInputNode: AnyObject {
/// The display node to insert into the view hierarchy and assign a frame to.
var asNode: ASDisplayNode { get }
/// Provider for animated custom-emoji views, resolved at render time. Set after creation.
var emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView?)? { get set }
/// Factory the PANEL supplies (it owns `AccountContext`) so the editor can render a `.media` block. The
/// native backend forwards it to the editor's media-view provider, resolving its private `mediaByID` →
/// this factory; the legacy `UITextView` backend stores it but never uses it (no media). `naturalSize` is
/// the medium's natural size, for aspect-correct display. Mirrors `emojiViewProvider`'s host-owned seam.
var mediaItemViewFactory: ((_ items: [(media: EngineMedia, naturalSize: CGSize, isSpoiler: Bool, kind: MediaKind)], _ existing: (UIView & RichTextMediaItemView)?) -> (UIView & RichTextMediaItemView)?)? { get set }
/// Host-provided formula renderer. The native backend forwards it to `RichTextEditorView`; the legacy
/// backend stores it but never uses it, matching the media seam.
var formulaRenderer: ((RichTextFormulaRenderContext) -> RichTextFormulaRenderResult?)? { get set }
/// Recompute and lay out the spoiler dust and custom-emoji overlays from the current
/// attributed text, hosting both inside the composed text view's scrolling content.
/// `textColor` / `fullTranslucency` are passed live on every call because the host's
/// theme and energy-usage settings can change between calls; the impl reuses the most
/// recent values for its own layout-driven refreshes.
func updateRichRendering(textColor: UIColor, fullTranslucency: Bool)
/// Cross-fade the spoiler dust for a reveal-state change, snapshotting the text so the
/// transition reads as a dissolve. The caller rewrites the attributed text first (that
/// is host/message-state domain); this method owns only the rendering side.
func setSpoilersRevealed(_ revealed: Bool, animated: Bool)
/// The bounding rect of the first selection rect for the given character range, in
/// `asNode`'s coordinate space (nil if the range cannot be resolved). Lets the host
/// anchor UI (e.g. the emoji-suggestion popup) to the caret without assuming a
/// `UITextView` backend or reaching into scroll-view geometry.
func firstSelectionRect(forCharacterRange characterRange: NSRange) -> CGRect?
/// The caret (insertion point) rect for the current selection, in `asNode`'s coordinate
/// space (nil if there is no caret). Like `firstSelectionRect`, this is a
/// backend-agnostic geometry query for anchoring UI to the cursor.
func currentCaretRect() -> CGRect?
/// The editor's attributed content. Reads return the current text; writes replace it
/// (the host rebuilds the attributed string after edits/state changes). Forwards to the
/// composed editor today; a future backend implements it natively.
var attributedText: NSAttributedString? { get set }
/// The editor's plain-text string (read-only convenience for length/counter checks).
var text: String { get }
/// Whether the editor currently holds NO content. Content-aware (mirrors `ChatInputContent.isEmpty`) —
/// NOT flat-text-aware, so a lone media/table block reads as non-empty even though its flat text is empty.
/// The default implementation answers from `attributedText` length (correct for the legacy backend, which
/// has no non-text blocks); the native backend overrides it with a direct document walk.
var inputContentIsEmpty: Bool { get }
/// As `inputContentIsEmpty`, but a block whose text is only whitespace counts as empty (mirrors
/// `ChatInputContent.isEmptyWhitespaceTrimmed`). Drives the composer's expand-button affordance, which stays
/// hidden for a field that is tall only because of blank lines. Same backend split as `inputContentIsEmpty`.
var inputContentIsEmptyWhitespaceTrimmed: Bool { get }
/// Apply host theme colors to the editor. No-op on the legacy `UITextView` backend (it themes via
/// `inputTheme`/`refreshTextInputAttributes`); the RichTextEditor backend maps these into its
/// `RichTextEditorTheme`.
func applyRichTextTheme(_ colors: ChatRichTextThemeColors)
/// The current selection range. Reads return the live selection; writes move/extend it
/// (the host restores the cursor after rebuilding content). Forwards to the editor today.
var selectedRange: NSRange { get set }
/// The bounding rect of the current selection in the editor's coordinate space (used to
/// anchor the system text-style menu). Read-only; forwarded as-is.
var selectionRect: CGRect { get }
/// Whether the editor currently holds first-responder (keyboard) focus.
/// Distinct from `ASDisplayNode.isFirstResponder()` (which would target the wrapper).
var isInputFirstResponder: Bool { get }
/// Make the composed editor first responder. Returns whether it succeeded.
@discardableResult func makeInputFirstResponder() -> Bool
/// Resign the composed editor's first-responder status. Returns whether it succeeded.
@discardableResult func resignInputFirstResponder() -> Bool
/// The active input mode's primary language (for input-language tracking). Read-only.
var primaryLanguage: String? { get }
/// The language the editor should default its keyboard to on next focus (get/set).
var initialPrimaryLanguage: String? { get set }
/// Re-apply `initialPrimaryLanguage` to the live input (used when forcing the emoji keyboard).
func resetInitialPrimaryLanguage()
/// The text container inset (caret/text padding). Recomputed by the host each layout pass.
var textContainerInset: UIEdgeInsets { get set }
/// The keyboard appearance (light/dark) for the editor.
var keyboardAppearance: UIKeyboardAppearance { get set }
/// Whether predictive text / autocorrection is enabled.
var autocorrectionType: UITextAutocorrectionType { get set }
/// The editor's tint (caret + selection) color. `input`-prefixed because the impl is an
/// `ASDisplayNode` whose own `tintColor` targets the wrapper, not the composed editor.
var inputTintColor: UIColor? { get set }
/// Propagate a tint-color change to the editor (mirrors `tintColorDidChange`).
func didChangeInputTintColor()
/// Hit-test slop expanding the editor's touch area. `input`-prefixed (wrapper collision).
var inputHitTestSlop: UIEdgeInsets { get set }
/// Whether the editor's scrolling content clips to bounds. `input`-prefixed (collision).
var inputClipsToBounds: Bool { get set }
/// The accessibility hint announced for the editor. `input`-prefixed (NSObject collision).
var inputAccessibilityHint: String? { get set }
/// Whether the editor accepts user interaction. `input`-prefixed because the impl's own
/// `ASDisplayNode.isUserInteractionEnabled` targets the wrapper.
var inputIsUserInteractionEnabled: Bool { get set }
/// Callback to collapse/expand a quote block at a range (set once after creation).
var toggleQuoteCollapse: ((NSRange) -> Void)? { get set }
/// Delete one character backward from the caret (UIKeyInput-style).
func deleteBackward()
/// Whether the editor is currently in the emoji input mode.
func isCurrentlyEmoji() -> Bool
/// The attributes applied to text typed next at the caret (font/color/paragraph + the
/// active bold/italic/monospace run). Generic text-editing concept; forwards to the
/// editor's typing attributes.
var inputTypingAttributes: [NSAttributedString.Key: Any] { get set }
/// Re-derive the full attribute set of the current text (fonts, colors, spoilers,
/// custom-emoji & collapsed-quote attachments) from theme-derived colors and state.
/// The impl supplies its own collapsed-quote attachment factory.
func refreshTextInputAttributes(
context: AnyObject,
primaryTextColor: UIColor,
accentTextColor: UIColor,
baseFontSize: CGFloat,
spoilersRevealed: Bool,
availableEmojis: Set<String>,
emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?
)
/// Re-derive only the typing attributes from the theme-derived text color.
func refreshTextInputTypingAttributes(textColor: UIColor, baseFontSize: CGFloat)
/// Set the insets for the editor's scroll indicator. No-op for non-scrolling backends.
/// (A setter method rather than a property: `UIScrollView.scrollIndicatorInsets`'s
/// getter is deprecated, and only the setter is needed here.)
func setInputScrollIndicatorInsets(_ insets: UIEdgeInsets)
/// Freeze the editor's scrolling ahead of a spoiler-reveal content rewrite, so the
/// attributed-text mutation doesn't auto-scroll. `setSpoilersRevealed(_:animated:)`
/// re-enables scrolling when the reveal completes. No-op for non-scrolling backends.
func prepareForSpoilerReveal()
/// The editor's current content scroll offset, used to map content-space rects into
/// the visible coordinate space. `.zero` for non-scrolling backends.
var inputContentOffset: CGPoint { get }
/// Measure the editor's content height for a width (mirrors the child's signature).
func textHeightForWidth(_ width: CGFloat, rightInset: CGFloat) -> CGFloat
/// Measures the field's content height for `lineCount` body lines (used to size the 3-line AI-button
/// trigger). Side-effect-free: builds a throwaway probe, never touches the live editor — but it is an
/// INSTANCE method because it must measure with this node's live content inset (rich: `trackedContentMargins`;
/// legacy: the text view's `textContainerInset`), the same inset the live `textHeightForWidth` adds, so the
/// 3-line size matches the real field exactly. `lineCount` floors at 1.
func measuredTextFieldHeight(forWidth width: CGFloat, lineCount: Int) -> CGFloat
/// Lay out the editor's content for a size.
func updateLayout(size: CGSize)
/// Force an immediate layout pass of the editor child (distinct name: `ASDisplayNode`
/// already has `layout()`, which targets the wrapper).
func layoutInputField()
/// The editor child's own frame, inset within `asNode` per the Model-A geometry.
/// Distinct from `asNode.frame` (the full-size wrapper).
var textFieldFrame: CGRect { get set }
/// The editor child's backing view (for gesture/snapshot/layout hooks). Backend-
/// agnostic: a direct backend returns its own content view (== `asNode.view` there).
var inputView: UIView { get }
/// The caret/selection tint. Distinct from `inputTintColor` (the node-level tint):
/// this is the inner editor's caret color, toggled to hide the caret during snapshots.
var inputCaretColor: UIColor { get set }
/// The editor's quote/code rendering theme (quote background/foreground, line style,
/// code colors). Set on load and on theme change.
var inputTheme: ChatInputTextView.Theme? { get set }
/// The editor's delegate (text/selection/editing/paste callbacks). Set once after
/// creation. Weak at the storage layer (the composed node holds it weakly).
var inputDelegate: ChatInputTextNodeDelegate? { get set }
/// The editor's custom keyboard input view (`nil` ⇒ system keyboard). Distinct from
/// `inputView` (the editor's backing view): this is the `UITextView.inputView` slot,
/// set to an empty view to suppress the system keyboard for the entity keyboard.
var keyboardInputView: UIView? { get set }
/// Reload the editor's input views (after changing `keyboardInputView`).
func reloadInputViews()
/// The editor's current right text inset (composer accessory width); read by the
/// send-options morph to match the bubble's text wrapping.
var currentRightInset: CGFloat { get }
/// Commit any pending marked/autocorrect text (called before send). For the UITextView
/// backend this applies keyboard autocorrection; another backend finalizes marked text.
func applyAutocorrection()
/// Whether this backend uses the native TextKit-2 `Document` engine (the new editor). The legacy
/// `UITextView` backend returns `false`. Used by the host (e.g. `openLinkEditing`) to route through the
/// native format API instead of the legacy `ChatTextInputState`.
var usesNativeRichTextEngine: Bool { get }
/// Transform of the editor backend's default edit-menu elements (e.g. swap in the chat "Format" submenu),
/// forwarded to the editor's menu. No-op on the legacy backend (it uses the `chatInputTextNodeMenu` delegate).
var contextMenuItemsProvider: ((_ defaultElements: [UIMenuElement]) -> [UIMenuElement])? { get set }
/// Fired when the native editor wants its table row/column structural menu presented; the PANEL presents it
/// as a ContextController. No-op on the legacy backend (it has no tables).
var onRequestTableStructuralMenu: ((TableStructuralMenuRequest) -> Void)? { get set }
/// Fired when a media control (the more button; the "+" later) is tapped; the PANEL presents an
/// owner-specific menu. No-op on the legacy backend (its editor has no inline media).
var onRequestMediaControl: ((MediaControlContext) -> Void)? { get set }
/// Pasting media (image/gif/video/sticker) is a host concern. The PANEL sets these so the native editor
/// can delegate a media paste back to the chat send flow. `canPasteMedia` gates whether the editor offers
/// Paste for the current pasteboard; `onPasteMedia` performs the routing (returns whether it consumed it).
var canPasteMedia: (() -> Bool)? { get set }
var onPasteMedia: (() -> Bool)? { get set }
/// Paste the richest representation currently on the system pasteboard at the caret using the editor's own
/// reader + fragment splice (private fragment UTI → RTF → plain). Native backend only; the legacy backend is
/// a no-op (the host routes its paste through the `NSAttributedString` path instead).
func performRichPaste()
/// Host transform for pasted PLAIN text, forwarded to the native editor's
/// `plainTextFragmentTransformer`. Returning a `Document` splices rich content at the caret;
/// returning nil keeps the built-in plain paste. Native backend only — legacy backend ignores it.
var pastedMarkdownFragmentParser: ((String) -> Document?)? { get set }
/// Fired on a genuine user TEXT edit (typing/delete/paste/IME) so the host can report the "typing…" chat
/// activity. Set by the PANEL, wired to its `updateActivity`. It must NOT fire on a caret/selection move or
/// on a programmatic content set (draft restore / send-clear / state echo) — otherwise the chat partner
/// sees "typing…" when the user merely moves the cursor or a draft loads. The LEGACY backend leaves this
/// stored-but-unused (it reports activity via the `chatInputTextNode(shouldChangeTextIn:)` delegate, which
/// the native editor never calls); the native backend fires it from its content-change path.
var onTypingActivity: (() -> Void)? { get set }
/// Apply a format command through the backend's native engine. No-op on the legacy backend.
func performFormatAction(_ action: ChatRichTextFormatAction)
/// The link URL the current selection carries (for prefilling a link editor), or nil.
func currentRichTextLinkURL() -> String?
/// The plain text of the current selection (for a link editor's prompt label).
func selectedRichText() -> String
/// Apply (or, for empty/nil, remove) a link over the current selection via the native engine.
func applyRichTextLink(_ url: String?)
/// The node's content as the canonical `ChatInputContent` model + opaque selection. Default
/// implementations bridge through `attributedText`/`selectedRange` + the TextFormat conversion utility.
func currentInputContent() -> (content: ChatInputContent, selection: ChatInputSelection)
func setInputContent(_ content: ChatInputContent, selection: ChatInputSelection)
/// Supply the host rendering inputs the LEGACY backend needs to decorate a `ChatInputContent` into the
/// displayed `NSAttributedString` inside `setInputContent` (fonts/colors/spoiler/emoji overlays) AND to run
/// the in-place per-edit fix-up (`refreshTextInputAttributes`) the node now drives itself. No-op on the
/// native backend (it renders from its own `Document`). `textColor` is the body color for the
/// `setInputContent` decorate step (the host's `inputTextColor`); `primaryTextColor` is the body color the
/// in-place fix-up re-derives with (the host's `primaryTextColor`) — these are deliberately distinct, so the
/// node must carry both. The host calls this whenever those inputs change (theme/energy/spoilers + before a
/// drive), so the stored config is never stale for the node's self-refreshes.
func applyRenderingConfig(context: AnyObject, baseFontSize: CGFloat, textColor: UIColor, primaryTextColor: UIColor, accentTextColor: UIColor, spoilersRevealed: Bool, availableEmojis: Set<String>, emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?)
/// Re-run the full per-text-change decoration the node owns: the in-place fix-up
/// (`refreshTextInputAttributes`), the caret typing attributes (`refreshTextInputTypingAttributes`), and the
/// spoiler-dust + custom-emoji overlay rebuild (`updateRichRendering`). The host calls this once at each
/// text-change moment with the current theme/energy inputs (so values are never stale), instead of driving
/// those three steps itself. `textColor` is the overlay/dust color (the host's `inputTextColor`);
/// `primaryTextColor` is the body color for the fix-up + typing attributes. The default implementation
/// forwards to the three steps in order, so both backends keep their existing behavior.
func decorateAfterTextChange(context: AnyObject, baseFontSize: CGFloat, textColor: UIColor, primaryTextColor: UIColor, accentTextColor: UIColor, spoilersRevealed: Bool, fullTranslucency: Bool, availableEmojis: Set<String>, emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?)
/// Whether spoilers are currently revealed. The node owns this state: the decoration reads it (via the
/// `spoilersRevealed:` arg the host passes from here) and the spoiler-reveal flow (`updateSpoilersRevealed`)
/// flips it. Replaces the former host-side flag.
var spoilersRevealed: Bool { get set }
/// Run the spoiler-reveal flow: detect whether the current selection intersects a spoiler and, if that
/// changed, reveal it immediately (or hide it after the un-reveal delay), re-decorating with the flipped
/// flag and cross-fading the dust. The host calls this at selection-change and after applying the spoiler
/// format, passing the live theme inputs; the default implementation owns the whole flow (detection +
/// 1.5s hide delay + dust animation), so the chat layer no longer drives it.
func updateSpoilersRevealed(context: AnyObject, baseFontSize: CGFloat, textColor: UIColor, primaryTextColor: UIColor, accentTextColor: UIColor, availableEmojis: Set<String>, emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?, animated: Bool)
/// Decorate a plain-text replacement fragment (for the paste / `shouldChangeText` splice) the way the node
/// renders content: applies font/colors and clears spoiler text using the node's own `spoilersRevealed`.
/// Returns the decorated fragment for the host to splice in — so the chat layer no longer calls
/// `textAttributedStringForStateText` itself. The host passes the live theme inputs.
func decorateReplacementFragment(plainText: String, context: AnyObject, baseFontSize: CGFloat, textColor: UIColor, accentTextColor: UIColor, availableEmojis: Set<String>, emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?) -> NSAttributedString
}
public extension ChatRichTextInputNode {
var inputContentIsEmpty: Bool {
return (self.attributedText?.length ?? 0) == 0
}
var inputContentIsEmptyWhitespaceTrimmed: Bool {
return (self.attributedText?.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) ?? true
}
func currentInputContent() -> (content: ChatInputContent, selection: ChatInputSelection) {
let attr = self.attributedText ?? NSAttributedString()
let content = chatInputContent(from: attr)
return (content, ChatInputSelection(nsRange: self.selectedRange, in: content))
}
func setInputContent(_ content: ChatInputContent, selection: ChatInputSelection) {
self.attributedText = attributedString(from: content)
self.selectedRange = selection.nsRange(in: content)
}
func applyRenderingConfig(context: AnyObject, baseFontSize: CGFloat, textColor: UIColor, primaryTextColor: UIColor, accentTextColor: UIColor, spoilersRevealed: Bool, availableEmojis: Set<String>, emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?) {}
func decorateAfterTextChange(context: AnyObject, baseFontSize: CGFloat, textColor: UIColor, primaryTextColor: UIColor, accentTextColor: UIColor, spoilersRevealed: Bool, fullTranslucency: Bool, availableEmojis: Set<String>, emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?) {
// The three decoration steps the host used to drive directly in `chatInputTextNodeDidUpdateText`, now
// owned by the node — same order, same live inputs. Each step is an existing backend method, so this
// forwards identically for both the legacy and native backends.
self.refreshTextInputAttributes(context: context, primaryTextColor: primaryTextColor, accentTextColor: accentTextColor, baseFontSize: baseFontSize, spoilersRevealed: spoilersRevealed, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider)
self.refreshTextInputTypingAttributes(textColor: primaryTextColor, baseFontSize: baseFontSize)
self.updateRichRendering(textColor: textColor, fullTranslucency: fullTranslucency)
}
func decorateReplacementFragment(plainText: String, context: AnyObject, baseFontSize: CGFloat, textColor: UIColor, accentTextColor: UIColor, availableEmojis: Set<String>, emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?) -> NSAttributedString {
return textAttributedStringForStateText(context: context, stateText: NSAttributedString(string: plainText), fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider, makeCollapsedQuoteAttachment: { text, attributes in
return ChatInputTextCollapsedQuoteAttachmentImpl(text: text, attributes: attributes)
})
}
func updateSpoilersRevealed(context: AnyObject, baseFontSize: CGFloat, textColor: UIColor, primaryTextColor: UIColor, accentTextColor: UIColor, availableEmojis: Set<String>, emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?, animated: Bool) {
// Detect whether the caret/selection now intersects a spoiler (same scan the host used to run).
let selectionRange = self.selectedRange
var revealed = false
if let attributedText = self.attributedText {
attributedText.enumerateAttributes(in: NSMakeRange(0, attributedText.length), options: [], using: { attributes, range, _ in
if let _ = attributes[ChatTextInputAttributes.spoiler] {
if let _ = selectionRange.intersection(range) {
revealed = true
}
}
})
}
guard self.spoilersRevealed != revealed else {
return
}
self.spoilersRevealed = revealed
if revealed {
self.applyInternalSpoilersRevealed(true, animated: animated, context: context, baseFontSize: baseFontSize, textColor: textColor, primaryTextColor: primaryTextColor, accentTextColor: accentTextColor, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider)
} else {
// Keep the spoiler revealed briefly after the caret leaves it, mirroring the host's prior timing.
Queue.mainQueue().after(1.5, { [weak self] in
guard let self else {
return
}
self.applyInternalSpoilersRevealed(false, animated: true, context: context, baseFontSize: baseFontSize, textColor: textColor, primaryTextColor: primaryTextColor, accentTextColor: accentTextColor, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider)
})
}
}
}
private extension ChatRichTextInputNode {
/// The reveal/hide half of `updateSpoilersRevealed`: prepare the editor, re-decorate the current content
/// with the flipped flag (length-preserving, so the selection is kept), then cross-fade the dust. Guards on
/// the flag still matching `revealed` (the caret may have moved back during the un-reveal delay). Mirrors
/// the host's former `updateInternalSpoilersRevealed`.
func applyInternalSpoilersRevealed(_ revealed: Bool, animated: Bool, context: AnyObject, baseFontSize: CGFloat, textColor: UIColor, primaryTextColor: UIColor, accentTextColor: UIColor, availableEmojis: Set<String>, emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?) {
guard self.spoilersRevealed == revealed else {
return
}
self.prepareForSpoilerReveal()
self.refreshTextInputAttributes(context: context, primaryTextColor: primaryTextColor, accentTextColor: accentTextColor, baseFontSize: baseFontSize, spoilersRevealed: self.spoilersRevealed, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider)
if !self.usesNativeRichTextEngine {
self.applyRenderingConfig(context: context, baseFontSize: baseFontSize, textColor: textColor, primaryTextColor: primaryTextColor, accentTextColor: accentTextColor, spoilersRevealed: self.spoilersRevealed, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider)
}
// Re-decorate the current content with the flipped spoilers-revealed flag; the string length is
// unchanged, so the selection is preserved across the rewrite. `currentInputContent()` is exactly the
// host's former `chatInputContent(from: inputTextState.inputText)` + current selection.
let current = self.currentInputContent()
self.setInputContent(current.content, selection: current.selection)
self.setSpoilersRevealed(revealed, animated: animated)
}
}
/// Creates the default composition-based rich text input node.
public func makeChatRichTextInputNode(disableTiling: Bool = false) -> ChatRichTextInputNode {
return ChatRichTextInputNodeImpl(disableTiling: disableTiling)
}
final class ChatRichTextInputNodeImpl: ASDisplayNode, ChatRichTextInputNode {
private let textInputNodeImpl: ChatInputTextNode
private var dustNode: InvisibleInkDustNode?
private var customEmojiContainerView: CustomEmojiContainerView?
// The node owns the spoilers-revealed state (was a host-side flag). Drives the dust alpha in
// `updateRichRendering` and the `spoilersRevealed:` decoration arg the host now reads back from here.
var spoilersRevealed: Bool = false
// Most recent values supplied by the host via updateRichRendering. The internal
// layout-driven refresh (onUpdateLayout) reuses them so it renders with the same
// theme/energy state the host last drove. nil textColor means "not configured yet".
private var lastTextColor: UIColor?
private var lastFullTranslucency: Bool = true
// Host rendering inputs for decorating a `ChatInputContent` inside `setInputContent`. Refreshed by the
// host (`applyRenderingConfig`) immediately before each `setInputContent` drive, so it is never stale.
private struct RenderingConfig {
weak var context: AnyObject?
var baseFontSize: CGFloat
var textColor: UIColor
var primaryTextColor: UIColor
var accentTextColor: UIColor
var spoilersRevealed: Bool
var availableEmojis: Set<String>
var emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?
}
private var renderingConfig: RenderingConfig?
var emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView?)?
// Stored-but-unused: the legacy `UITextView` backend has no media blocks to render, so it satisfies the
// protocol but never reads this. The native (`RichTextEditorChatInputNode`) backend wires it to the editor.
var mediaItemViewFactory: ((_ items: [(media: EngineMedia, naturalSize: CGSize, isSpoiler: Bool, kind: MediaKind)], _ existing: (UIView & RichTextMediaItemView)?) -> (UIView & RichTextMediaItemView)?)?
// Stored-but-unused: the legacy backend has no formula atoms to render. The native backend wires this into
// `RichTextEditorView`.
var formulaRenderer: ((RichTextFormulaRenderContext) -> RichTextFormulaRenderResult?)?
// Stored-but-unused: the legacy backend reports "typing…" activity via the panel's
// `chatInputTextNode(shouldChangeTextIn:)` delegate, so it never fires this. Only the native
// (`RichTextEditorChatInputNode`) backend fires it (it doesn't call that delegate). See the protocol doc.
var onTypingActivity: (() -> Void)?
// Bridges to ASDisplayNode for callers that hold this only as a ChatRichTextInputNode.
var asNode: ASDisplayNode {
return self
}
init(disableTiling: Bool = false) {
self.textInputNodeImpl = ChatInputTextNode(disableTiling: disableTiling)
super.init()
self.addSubnode(self.textInputNodeImpl)
// Refresh overlays when the composed editor relayouts (e.g. content size changes
// on scroll/typing), reusing the host's most recent rendering values.
self.textInputNodeImpl.textView.onUpdateLayout = { [weak self] in
guard let self, let textColor = self.lastTextColor else {
return
}
self.updateRichRendering(textColor: textColor, fullTranslucency: self.lastFullTranslucency)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updateRichRendering(textColor: UIColor, fullTranslucency: Bool) {
self.lastTextColor = textColor
self.lastFullTranslucency = fullTranslucency
let textInputNode = self.textInputNodeImpl
var rects: [CGRect] = []
var customEmojiRects: [(CGRect, ChatTextInputTextCustomEmojiAttribute, CGFloat)] = []
// The host pins the composer font size to 17.0; match it here.
let fontSize: CGFloat = 17.0
if let attributedText = textInputNode.attributedText {
let beginning = textInputNode.textView.beginningOfDocument
attributedText.enumerateAttributes(in: NSMakeRange(0, attributedText.length), options: [], using: { attributes, range, _ in
if let _ = attributes[ChatTextInputAttributes.spoiler] {
func addSpoiler(startIndex: Int, endIndex: Int) {
if let start = textInputNode.textView.position(from: beginning, offset: startIndex), let end = textInputNode.textView.position(from: start, offset: endIndex - startIndex), let textRange = textInputNode.textView.textRange(from: start, to: end) {
let textRects = textInputNode.textView.selectionRects(for: textRange)
for textRect in textRects {
if textRect.rect.width > 1.0 && textRect.rect.size.height > 1.0 {
rects.append(textRect.rect.insetBy(dx: 1.0, dy: 1.0).offsetBy(dx: 0.0, dy: 1.0))
}
}
}
}
var startIndex: Int?
var currentIndex: Int?
let nsString = (attributedText.string as NSString)
nsString.enumerateSubstrings(in: range, options: .byComposedCharacterSequences) { substring, range, _, _ in
if let substring = substring, substring.rangeOfCharacter(from: .whitespacesAndNewlines) != nil {
if let currentStartIndex = startIndex {
startIndex = nil
let endIndex = range.location
addSpoiler(startIndex: currentStartIndex, endIndex: endIndex)
}
} else if startIndex == nil {
startIndex = range.location
}
currentIndex = range.location + range.length
}
if let currentStartIndex = startIndex, let currentIndex = currentIndex {
startIndex = nil
let endIndex = currentIndex
addSpoiler(startIndex: currentStartIndex, endIndex: endIndex)
}
}
if let value = attributes[ChatTextInputAttributes.customEmoji] as? ChatTextInputTextCustomEmojiAttribute {
if let start = textInputNode.textView.position(from: beginning, offset: range.location), let end = textInputNode.textView.position(from: start, offset: range.length), let textRange = textInputNode.textView.textRange(from: start, to: end) {
let textRects = textInputNode.textView.selectionRects(for: textRange)
for textRect in textRects {
var emojiFontSize = fontSize
if let font = attributes[.font] as? UIFont {
emojiFontSize = font.pointSize
}
customEmojiRects.append((textRect.rect, value, emojiFontSize))
break
}
}
}
})
}
if !rects.isEmpty {
let dustNode: InvisibleInkDustNode
if let current = self.dustNode {
dustNode = current
} else {
dustNode = InvisibleInkDustNode(textNode: nil, enableAnimations: fullTranslucency)
dustNode.alpha = self.spoilersRevealed ? 0.0 : 1.0
dustNode.isUserInteractionEnabled = false
textInputNode.textView.addSubview(dustNode.view)
self.dustNode = dustNode
}
dustNode.frame = CGRect(origin: CGPoint(), size: textInputNode.textView.contentSize)
dustNode.update(size: textInputNode.textView.contentSize, color: textColor, textColor: textColor, rects: rects, wordRects: rects)
} else if let dustNode = self.dustNode {
dustNode.removeFromSupernode()
self.dustNode = nil
}
if !customEmojiRects.isEmpty {
let customEmojiContainerView: CustomEmojiContainerView
if let current = self.customEmojiContainerView {
customEmojiContainerView = current
} else {
customEmojiContainerView = CustomEmojiContainerView(emojiViewProvider: { [weak self] emoji in
guard let strongSelf = self, let emojiViewProvider = strongSelf.emojiViewProvider else {
return nil
}
return emojiViewProvider(emoji)
})
customEmojiContainerView.isUserInteractionEnabled = false
textInputNode.textView.addSubview(customEmojiContainerView)
self.customEmojiContainerView = customEmojiContainerView
}
customEmojiContainerView.update(fontSize: fontSize, textColor: textColor, emojiRects: customEmojiRects)
} else if let customEmojiContainerView = self.customEmojiContainerView {
customEmojiContainerView.removeFromSuperview()
self.customEmojiContainerView = nil
}
}
func setSpoilersRevealed(_ revealed: Bool, animated: Bool) {
self.spoilersRevealed = revealed
let textView = self.textInputNodeImpl.textView
if textView.subviews.count > 1, animated {
let containerView = textView.subviews[1]
if let canvasView = containerView.subviews.first {
if let snapshotView = canvasView.snapshotView(afterScreenUpdates: false) {
snapshotView.frame = canvasView.frame.offsetBy(dx: 0.0, dy: -textView.contentOffset.y)
self.textInputNodeImpl.view.insertSubview(snapshotView, at: 0)
canvasView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak snapshotView, weak textView] _ in
textView?.isScrollEnabled = false
snapshotView?.removeFromSuperview()
Queue.mainQueue().after(0.1) {
textView?.isScrollEnabled = true
}
})
}
}
}
Queue.mainQueue().after(0.1) { [weak textView] in
textView?.isScrollEnabled = true
}
if animated {
let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .linear)
if let dustNode = self.dustNode {
transition.updateAlpha(node: dustNode, alpha: revealed ? 0.0 : 1.0)
}
} else if let dustNode = self.dustNode {
dustNode.alpha = revealed ? 0.0 : 1.0
}
}
func firstSelectionRect(forCharacterRange characterRange: NSRange) -> CGRect? {
let textView = self.textInputNodeImpl.textView
let beginning = textView.beginningOfDocument
guard let start = textView.position(from: beginning, offset: characterRange.location),
let end = textView.position(from: start, offset: characterRange.length),
let textRange = textView.textRange(from: start, to: end),
let rect = textView.selectionRects(for: textRange).first?.rect else {
return nil
}
// selectionRects are in the text view's (scrolling) coordinate space; convert up to
// this node's space so the caller never depends on the editor being a scroll view.
return textView.convert(rect, to: self.view)
}
func currentCaretRect() -> CGRect? {
let textView = self.textInputNodeImpl.textView
guard let selectedTextRange = textView.selectedTextRange else {
return nil
}
let rect = textView.caretRect(for: selectedTextRange.end)
guard rect.origin.x.isFinite, rect.origin.y.isFinite, rect.size.width.isFinite, rect.size.height.isFinite else {
return nil
}
return textView.convert(rect, to: self.view)
}
var attributedText: NSAttributedString? {
get { return self.textInputNodeImpl.attributedText }
set { self.textInputNodeImpl.attributedText = newValue }
}
var text: String {
return self.textInputNodeImpl.textView.text
}
/// No-op: the legacy `UITextView` backend themes via `inputTheme`/`refreshTextInputAttributes`.
func applyRichTextTheme(_ colors: ChatRichTextThemeColors) {
}
var selectedRange: NSRange {
get { return self.textInputNodeImpl.selectedRange }
set { self.textInputNodeImpl.selectedRange = newValue }
}
var selectionRect: CGRect {
return self.textInputNodeImpl.selectionRect
}
var isInputFirstResponder: Bool {
return self.textInputNodeImpl.isFirstResponder()
}
@discardableResult func makeInputFirstResponder() -> Bool {
return self.textInputNodeImpl.becomeFirstResponder()
}
@discardableResult func resignInputFirstResponder() -> Bool {
return self.textInputNodeImpl.resignFirstResponder()
}
var primaryLanguage: String? {
return self.textInputNodeImpl.textInputMode?.primaryLanguage
}
var initialPrimaryLanguage: String? {
get { return self.textInputNodeImpl.initialPrimaryLanguage }
set { self.textInputNodeImpl.initialPrimaryLanguage = newValue }
}
func resetInitialPrimaryLanguage() {
self.textInputNodeImpl.resetInitialPrimaryLanguage()
}
var textContainerInset: UIEdgeInsets {
get { return self.textInputNodeImpl.textContainerInset }
set { self.textInputNodeImpl.textContainerInset = newValue }
}
var keyboardAppearance: UIKeyboardAppearance {
get { return self.textInputNodeImpl.keyboardAppearance }
set { self.textInputNodeImpl.keyboardAppearance = newValue }
}
var autocorrectionType: UITextAutocorrectionType {
get { return self.textInputNodeImpl.textView.autocorrectionType }
set { self.textInputNodeImpl.textView.autocorrectionType = newValue }
}
var inputTintColor: UIColor? {
get { return self.textInputNodeImpl.tintColor }
set { self.textInputNodeImpl.tintColor = newValue }
}
func didChangeInputTintColor() {
self.textInputNodeImpl.tintColorDidChange()
}
var inputHitTestSlop: UIEdgeInsets {
get { return self.textInputNodeImpl.hitTestSlop }
set { self.textInputNodeImpl.hitTestSlop = newValue }
}
var inputClipsToBounds: Bool {
get { return self.textInputNodeImpl.textView.clipsToBounds }
set { self.textInputNodeImpl.textView.clipsToBounds = newValue }
}
var inputAccessibilityHint: String? {
get { return self.textInputNodeImpl.textView.accessibilityHint }
set { self.textInputNodeImpl.textView.accessibilityHint = newValue }
}
var inputIsUserInteractionEnabled: Bool {
get { return self.textInputNodeImpl.isUserInteractionEnabled }
set { self.textInputNodeImpl.isUserInteractionEnabled = newValue }
}
var toggleQuoteCollapse: ((NSRange) -> Void)? {
get { return self.textInputNodeImpl.textView.toggleQuoteCollapse }
set { self.textInputNodeImpl.textView.toggleQuoteCollapse = newValue }
}
func deleteBackward() {
self.textInputNodeImpl.textView.deleteBackward()
}
func isCurrentlyEmoji() -> Bool {
return self.textInputNodeImpl.isCurrentlyEmoji()
}
var inputTypingAttributes: [NSAttributedString.Key: Any] {
get { return self.textInputNodeImpl.textView.typingAttributes }
set { self.textInputNodeImpl.textView.typingAttributes = newValue }
}
func refreshTextInputAttributes(
context: AnyObject,
primaryTextColor: UIColor,
accentTextColor: UIColor,
baseFontSize: CGFloat,
spoilersRevealed: Bool,
availableEmojis: Set<String>,
emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?
) {
refreshChatTextInputAttributes(context: context, textView: self.textInputNodeImpl.textView, primaryTextColor: primaryTextColor, accentTextColor: accentTextColor, baseFontSize: baseFontSize, spoilersRevealed: spoilersRevealed, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider, makeCollapsedQuoteAttachment: { text, attributes in
return ChatInputTextCollapsedQuoteAttachmentImpl(text: text, attributes: attributes)
})
}
func refreshTextInputTypingAttributes(textColor: UIColor, baseFontSize: CGFloat) {
refreshChatTextInputTypingAttributes(self.textInputNodeImpl.textView, textColor: textColor, baseFontSize: baseFontSize)
}
// MARK: - ChatInputContent model seam (legacy backend)
func applyRenderingConfig(context: AnyObject, baseFontSize: CGFloat, textColor: UIColor, primaryTextColor: UIColor, accentTextColor: UIColor, spoilersRevealed: Bool, availableEmojis: Set<String>, emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?) {
self.renderingConfig = RenderingConfig(context: context, baseFontSize: baseFontSize, textColor: textColor, primaryTextColor: primaryTextColor, accentTextColor: accentTextColor, spoilersRevealed: spoilersRevealed, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider)
}
/// Run the in-place per-edit decoration fix-up on the live text storage, using the host inputs last supplied
/// via `applyRenderingConfig`. This is `refreshChatTextInputAttributes` — the typing-maintenance pass
/// (mention/url/quote range re-validation + re-decoration), NOT the same as `setInputContent`'s decorate
/// step. The node calls this itself (after a content set; later, on its own text changes), so the chat layer
/// no longer drives `refreshTextInputAttributes`. No-op until the host has configured the node.
private func refreshDecorationFromStoredConfig() {
guard let cfg = self.renderingConfig, let context = cfg.context else {
return
}
refreshChatTextInputAttributes(context: context, textView: self.textInputNodeImpl.textView, primaryTextColor: cfg.primaryTextColor, accentTextColor: cfg.accentTextColor, baseFontSize: cfg.baseFontSize, spoilersRevealed: cfg.spoilersRevealed, availableEmojis: cfg.availableEmojis, emojiViewProvider: cfg.emojiViewProvider, makeCollapsedQuoteAttachment: { text, attributes in
return ChatInputTextCollapsedQuoteAttachmentImpl(text: text, attributes: attributes)
})
}
func setInputContent(_ content: ChatInputContent, selection: ChatInputSelection) {
// Convert the model to its display-neutral string, then decorate it exactly as the host used to
// (fonts/colors/spoiler-clear/collapsed-quote attachments). `attributedString(from:)` round-trips the
// chat currency for the carried attribute set, so this is byte-identical to the former host path.
let semantic = attributedString(from: content)
if let cfg = self.renderingConfig, let context = cfg.context {
self.attributedText = textAttributedStringForStateText(context: context, stateText: semantic, fontSize: cfg.baseFontSize, textColor: cfg.textColor, accentTextColor: cfg.accentTextColor, writingDirection: nil, spoilersRevealed: cfg.spoilersRevealed, availableEmojis: cfg.availableEmojis, emojiViewProvider: cfg.emojiViewProvider, makeCollapsedQuoteAttachment: { text, attributes in
return ChatInputTextCollapsedQuoteAttachmentImpl(text: text, attributes: attributes)
})
} else {
// No host config yet (not expected at a drive site, since the host calls applyRenderingConfig first).
self.attributedText = semantic
}
self.selectedRange = selection.nsRange(in: content)
// Run the in-place fix-up the host used to call right after `setInputContent` (the former
// `refreshTextInputAttributes` at the drive sites). Same function, same stored inputs, same point in the
// sequence — so this is a 1:1 relocation. Overlay rebuild (`updateRichRendering`) still self-heals on the
// subsequent layout pass (`onUpdateLayout`).
self.refreshDecorationFromStoredConfig()
}
func currentInputContent() -> (content: ChatInputContent, selection: ChatInputSelection) {
// Strip the display decoration (emoji/collapsed-quote attachments → semantic attributes) before
// converting, mirroring the host's `stateAttributedStringForText` read-back.
let semantic = stateAttributedStringForText(self.attributedText ?? NSAttributedString())
let content = chatInputContent(from: semantic)
return (content, ChatInputSelection(nsRange: self.selectedRange, in: content))
}
func setInputScrollIndicatorInsets(_ insets: UIEdgeInsets) {
self.textInputNodeImpl.textView.scrollIndicatorInsets = insets
}
func prepareForSpoilerReveal() {
self.textInputNodeImpl.textView.isScrollEnabled = false
}
var inputContentOffset: CGPoint {
return self.textInputNodeImpl.textView.contentOffset
}
func textHeightForWidth(_ width: CGFloat, rightInset: CGFloat) -> CGFloat {
return self.textInputNodeImpl.textHeightForWidth(width, rightInset: rightInset)
}
func measuredTextFieldHeight(forWidth width: CGFloat, lineCount: Int) -> CGFloat {
let count = max(1, lineCount)
let text = Array(repeating: "A", count: count).joined(separator: "\n")
let attributed = NSAttributedString(string: text, attributes: [.font: UIFont.systemFont(ofSize: 17.0)])
let storage = NSTextStorage(attributedString: attributed)
let layoutManager = NSLayoutManager()
let container = NSTextContainer(size: CGSize(width: max(1.0, width), height: .greatestFiniteMagnitude))
container.lineFragmentPadding = 0.0
storage.addLayoutManager(layoutManager)
layoutManager.addTextContainer(container)
layoutManager.ensureLayout(for: container)
// Add the same vertical container inset the live `textHeightForWidth` adds (ChatInputTextView), so the
// probe matches the real field height.
let inset = self.textInputNodeImpl.textView.textContainerInset
return ceil(layoutManager.usedRect(for: container).height + inset.top + inset.bottom)
}
func updateLayout(size: CGSize) {
self.textInputNodeImpl.updateLayout(size: size)
}
func layoutInputField() {
self.textInputNodeImpl.layout()
}
var textFieldFrame: CGRect {
get { return self.textInputNodeImpl.frame }
set { self.textInputNodeImpl.frame = newValue }
}
var inputView: UIView {
return self.textInputNodeImpl.view
}
var inputCaretColor: UIColor {
get { return self.textInputNodeImpl.textView.tintColor }
set { self.textInputNodeImpl.textView.tintColor = newValue }
}
var inputTheme: ChatInputTextView.Theme? {
get { return self.textInputNodeImpl.textView.theme }
set { self.textInputNodeImpl.textView.theme = newValue }
}
var inputDelegate: ChatInputTextNodeDelegate? {
get { return self.textInputNodeImpl.delegate }
set { self.textInputNodeImpl.delegate = newValue }
}
var keyboardInputView: UIView? {
get { return self.textInputNodeImpl.textView.inputView }
set { self.textInputNodeImpl.textView.inputView = newValue }
}
func reloadInputViews() {
self.textInputNodeImpl.textView.reloadInputViews()
}
var currentRightInset: CGFloat {
return self.textInputNodeImpl.textView.currentRightInset
}
func applyAutocorrection() {
Keyboard.applyAutocorrection(textView: self.textInputNodeImpl.textView)
}
var usesNativeRichTextEngine: Bool { return false }
// The legacy backend builds its menu via ChatTextInputPanelNode.chatInputTextNodeMenu; this hook is unused.
var contextMenuItemsProvider: ((_ defaultElements: [UIMenuElement]) -> [UIMenuElement])?
// The legacy backend has no tables; this hook is never fired.
var onRequestTableStructuralMenu: ((TableStructuralMenuRequest) -> Void)?
// The legacy backend has no inline media; this hook is never fired.
var onRequestMediaControl: ((MediaControlContext) -> Void)?
// The legacy path routes media via `chatInputTextNodeShouldPaste`; these hooks are never read.
public var canPasteMedia: (() -> Bool)?
public var onPasteMedia: (() -> Bool)?
// The legacy backend has no native editor; the parser is stored but never consulted.
public var pastedMarkdownFragmentParser: ((String) -> Document?)?
func performFormatAction(_ action: ChatRichTextFormatAction) {}
func performRichPaste() {}
func currentRichTextLinkURL() -> String? { return nil }
func selectedRichText() -> String { return "" }
func applyRichTextLink(_ url: String?) {}
}