Text selection

This commit is contained in:
Isaac 2026-03-24 22:51:44 +08:00
parent 3920eb1507
commit 93bf472472
17 changed files with 266 additions and 60 deletions

View file

@ -190,7 +190,7 @@ public final class MultilineTextWithEntitiesComponent: Component {
public final class View: UIView {
var spoilerTextNode: ImmediateTextNodeWithEntities?
let textNode: ImmediateTextNodeWithEntities
public let textNode: ImmediateTextNodeWithEntities
public override init(frame: CGRect) {
self.textNode = ImmediateTextNodeWithEntities()

View file

@ -466,7 +466,7 @@ public final class ResizableSheetComponent<ChildEnvironmentType: Sendable & Equa
topOffsetFraction = 1.0
}
#if DEBUG
#if DEBUG && false
if "".isEmpty {
topOffsetFraction = 1.0
}

View file

@ -3,13 +3,23 @@ import UIKit
import AsyncDisplayKit
public final class ContextMenuControllerPresentationArguments {
public let sourceNodeAndRect: () -> (ASDisplayNode, CGRect, ASDisplayNode, CGRect)?
public let sourceViewAndRect: () -> (UIView, CGRect, UIView, CGRect)?
public let bounce: Bool
public init(sourceNodeAndRect: @escaping () -> (ASDisplayNode, CGRect, ASDisplayNode, CGRect)?, bounce: Bool = true) {
self.sourceNodeAndRect = sourceNodeAndRect
public init(sourceViewAndRect: @escaping () -> (UIView, CGRect, UIView, CGRect)?, bounce: Bool = true) {
self.sourceViewAndRect = sourceViewAndRect
self.bounce = bounce
}
public convenience init(sourceNodeAndRect: @escaping () -> (ASDisplayNode, CGRect, ASDisplayNode, CGRect)?, bounce: Bool = true) {
self.init(sourceViewAndRect: {
if let (view1, rect1, view2, rect2) = sourceNodeAndRect() {
return (view1.view, rect1, view2.view, rect2)
} else {
return nil
}
}, bounce: bounce)
}
}
public protocol ContextMenuController: ViewController, StandalonePresentableController {

View file

@ -459,18 +459,18 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, ASScroll
)
self.textNode.visibility = true
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: defaultDarkPresentationTheme.list.itemAccentColor.withMultipliedAlpha(0.5), knob: defaultDarkPresentationTheme.list.itemAccentColor, isDark: true), strings: presentationData.strings, textNode: self.textNode, updateIsActive: { [weak self] value in
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: defaultDarkPresentationTheme.list.itemAccentColor.withMultipliedAlpha(0.5), knob: defaultDarkPresentationTheme.list.itemAccentColor, isDark: true), strings: presentationData.strings, textNodeOrView: .node(self.textNode), updateIsActive: { [weak self] value in
guard let self else {
return
}
let _ = self
}, present: { c, a in
present(c, a)
}, rootNode: { [weak self] in
}, rootView: { [weak self] in
guard let self else {
return nil
}
return self.controllerInteraction?.controller()?.displayNode
return self.controllerInteraction?.controller()?.displayNode.view
}, externalKnobSurface: self.textSelectionKnobSurface, performAction: { [weak self] text, action in
guard let self else {
return

View file

@ -156,12 +156,12 @@ public class ChatMessageFactCheckBubbleContentNode: ChatMessageBubbleContentNode
let selectionColor: UIColor = item.presentationData.theme.theme.chat.message.incoming.textSelectionColor
let knobColor: UIColor = item.presentationData.theme.theme.chat.message.incoming.textSelectionKnobColor
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNode: self.textNode, updateIsActive: { [weak self] value in
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNodeOrView: .node(self.textNode), updateIsActive: { [weak self] value in
self?.updateIsTextSelectionActive?(value)
}, present: { [weak self] c, a in
self?.item?.controllerInteraction.presentGlobalOverlayController(c, a)
}, rootNode: { [weak rootNode] in
return rootNode
}, rootView: { [weak rootNode] in
return rootNode?.view
}, performAction: { [weak self] text, action in
guard let strongSelf = self, let item = strongSelf.item else {
return

View file

@ -2049,12 +2049,12 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode {
knobColor = item.presentationData.theme.theme.chat.message.outgoing.textSelectionKnobColor
}
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNode: self.textNode, updateIsActive: { [weak self] value in
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNodeOrView: .node(self.textNode), updateIsActive: { [weak self] value in
self?.updateIsTextSelectionActive?(value)
}, present: { [weak self] c, a in
self?.arguments?.controllerInteraction.presentGlobalOverlayController(c, a)
}, rootNode: { [weak rootNode] in
return rootNode
}, rootView: { [weak rootNode] in
return rootNode?.view
}, performAction: { [weak self] text, action in
guard let strongSelf = self, let item = strongSelf.arguments else {
return

View file

@ -1622,7 +1622,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
knobColor = item.presentationData.theme.theme.chat.message.outgoing.textSelectionKnobColor
}
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNode: self.textNode.textNode, updateIsActive: { [weak self] value in
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNodeOrView: .node(self.textNode.textNode), updateIsActive: { [weak self] value in
self?.updateIsTextSelectionActive?(value)
}, present: { [weak self] c, a in
guard let self, let item = self.item else {
@ -1634,8 +1634,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
} else {
item.controllerInteraction.presentGlobalOverlayController(c, a)
}
}, rootNode: { [weak rootNode] in
return rootNode
}, rootView: { [weak rootNode] in
return rootNode?.view
}, performAction: { [weak self] text, action in
guard let strongSelf = self, let item = strongSelf.item else {
return

View file

@ -3562,14 +3562,14 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg
var aiButtonAlpha: CGFloat = actualTextFieldFrame.height >= 70.0 ? 1.0 : 0.0
self.heightDependentAiButtonAlpha = aiButtonAlpha
var inputHasText = false
if let textInputNode = self.textInputNode, let attributedText = textInputNode.attributedText, attributedText.length != 0 {
if let textInputNode = self.textInputNode, let attributedText = textInputNode.attributedText, !attributedText.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
inputHasText = true
}
if !inputHasText {
aiButtonAlpha = 0.0
}
transition.updateAlpha(layer: aiButton.button.layer, alpha: aiButtonAlpha)
transition.updateAlpha(layer: aiButton.icon.layer, alpha: aiButtonAlpha)
ComponentTransition(transition).setAlpha(view: aiButton.button, alpha: aiButtonAlpha)
ComponentTransition(transition).setAlpha(view: aiButton.icon, alpha: aiButtonAlpha)
} else if let aiButton = self.aiButton {
self.aiButton = nil
let aiButtonView = aiButton.button
@ -4398,11 +4398,11 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg
if let aiButton = self.aiButton {
let transition: ContainedViewLayoutTransition = .immediate
var aiButtonAlpha: CGFloat = self.heightDependentAiButtonAlpha
if !inputHasText {
if let textInputNode = self.textInputNode, let attributedText = textInputNode.attributedText, !attributedText.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
aiButtonAlpha = 0.0
}
transition.updateAlpha(layer: aiButton.button.layer, alpha: aiButtonAlpha)
transition.updateAlpha(layer: aiButton.icon.layer, alpha: aiButtonAlpha)
ComponentTransition(transition).setAlpha(view: aiButton.button, alpha: aiButtonAlpha)
ComponentTransition(transition).setAlpha(view: aiButton.icon, alpha: aiButtonAlpha)
}
self.updateTextHeight(animated: animated)

View file

@ -438,10 +438,10 @@ final class InnerTextSelectionTipContainerNode: ASDisplayNode {
super.init()
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: presentationData.theme.contextMenu.primaryColor.withAlphaComponent(0.15), knob: presentationData.theme.contextMenu.primaryColor, knobDiameter: 8.0, isDark: presentationData.theme.overallDarkAppearance), strings: presentationData.strings, textNode: self.textNode.textNode, updateIsActive: { _ in
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: presentationData.theme.contextMenu.primaryColor.withAlphaComponent(0.15), knob: presentationData.theme.contextMenu.primaryColor, knobDiameter: 8.0, isDark: presentationData.theme.overallDarkAppearance), strings: presentationData.strings, textNodeOrView: .node(self.textNode.textNode), updateIsActive: { _ in
}, present: { _, _ in
}, rootNode: { [weak self] in
return self
}, rootView: { [weak self] in
return self?.view
}, performAction: { _, _ in
})
self.textSelectionNode = textSelectionNode

View file

@ -85,13 +85,13 @@ public final class ContextMenuControllerImpl: ViewController, KeyShortcutRespond
} else {
self.layout = layout
if let presentationArguments = self.presentationArguments as? ContextMenuControllerPresentationArguments, let (sourceNode, sourceRect, containerNode, containerRect) = presentationArguments.sourceNodeAndRect() {
if let presentationArguments = self.presentationArguments as? ContextMenuControllerPresentationArguments, let (sourceView, sourceRect, containerView, containerRect) = presentationArguments.sourceViewAndRect() {
if self.skipCoordnateConversion {
self.contextMenuNode.sourceRect = sourceRect
self.contextMenuNode.containerRect = containerRect
} else {
self.contextMenuNode.sourceRect = sourceNode.view.convert(sourceRect, to: nil)
self.contextMenuNode.containerRect = containerNode.view.convert(containerRect, to: nil)
self.contextMenuNode.sourceRect = sourceView.convert(sourceRect, to: nil)
self.contextMenuNode.containerRect = containerView.convert(containerRect, to: nil)
}
} else {
self.contextMenuNode.sourceRect = nil

View file

@ -402,7 +402,7 @@ final class CreateBotContentComponent: Component {
theme: environment.theme,
strings: environment.strings,
action: { [weak self] in
guard let self, let itemView = self.nameSection.findTaggedView(tag: self.usernameInputTag) as? ListMultilineTextFieldItemComponent.View else {
guard let self, let itemView = self.usernameSection.findTaggedView(tag: self.usernameInputTag) as? ListMultilineTextFieldItemComponent.View else {
return
}
itemView.activateInput()

View file

@ -896,7 +896,7 @@ final class StoryContentCaptionComponent: Component {
self.textSelectionKnobContainer.addSubview(textSelectionKnobSurface)
}
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: component.theme.list.itemAccentColor, isDark: true), strings: component.strings, textNode: textNode, updateIsActive: { [weak self] value in
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: component.theme.list.itemAccentColor, isDark: true), strings: component.strings, textNodeOrView: .node(textNode), updateIsActive: { [weak self] value in
guard let self else {
return
}
@ -912,8 +912,8 @@ final class StoryContentCaptionComponent: Component {
return
}
component.controller()?.presentInGlobalOverlay(c, with: a)
}, rootNode: { [weak controller] in
return controller?.displayNode
}, rootView: { [weak controller] in
return controller?.displayNode.view
}, externalKnobSurface: self.textSelectionKnobSurface, performAction: { [weak self] text, action in
guard let self, let component = self.component else {
return

View file

@ -41,6 +41,9 @@ swift_library(
"//submodules/Markdown",
"//submodules/TelegramUIPreferences",
"//submodules/ChatSendMessageActionUI",
"//submodules/TextSelectionNode",
"//submodules/Pasteboard",
"//submodules/Speak",
],
visibility = [
"//visibility:public",

View file

@ -84,6 +84,7 @@ final class TextProcessingContentComponent: Component {
final class View: UIView {
private var component: TextProcessingContentComponent?
private var environment: ViewControllerComponentContainer.Environment?
private weak var state: EmptyComponentState?
private var isUpdating: Bool = false
@ -213,6 +214,7 @@ final class TextProcessingContentComponent: Component {
}
self.component = component
self.environment = environment
self.state = state
let sideInset: CGFloat = 16.0
@ -351,7 +353,13 @@ final class TextProcessingContentComponent: Component {
inputText: component.inputText,
mode: .translate,
copyAction: component.copyCurrentResult,
displayLanguageSelectionMenu: component.displayLanguageSelectionMenu
displayLanguageSelectionMenu: component.displayLanguageSelectionMenu,
present: { [weak self] c, a in
self?.environment?.controller()?.present(c, in: .window(.root), with: a)
},
rootViewForTextSelection: { [weak self] in
return self?.environment?.controller()?.view
}
))
case .stylize:
contentComponent = AnyComponent(TextProcessingTranslateContentComponent(
@ -363,7 +371,13 @@ final class TextProcessingContentComponent: Component {
inputText: component.inputText,
mode: .stylize,
copyAction: component.copyCurrentResult,
displayLanguageSelectionMenu: component.displayLanguageSelectionMenu
displayLanguageSelectionMenu: component.displayLanguageSelectionMenu,
present: { [weak self] c, a in
self?.environment?.controller()?.present(c, in: .window(.root), with: a)
},
rootViewForTextSelection: { [weak self] in
return self?.environment?.controller()?.view
}
))
case .fix:
contentComponent = AnyComponent(TextProcessingTranslateContentComponent(
@ -375,7 +389,13 @@ final class TextProcessingContentComponent: Component {
inputText: component.inputText,
mode: .fix,
copyAction: component.copyCurrentResult,
displayLanguageSelectionMenu: component.displayLanguageSelectionMenu
displayLanguageSelectionMenu: component.displayLanguageSelectionMenu,
present: { [weak self] c, a in
self?.environment?.controller()?.present(c, in: .window(.root), with: a)
},
rootViewForTextSelection: { [weak self] in
return self?.environment?.controller()?.view
}
))
}

View file

@ -13,10 +13,14 @@ import TextFormat
import PlainButtonComponent
import CheckComponent
import ShimmerEffect
import TextSelectionNode
import Pasteboard
import Speak
final class TextProcessingTextAreaComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let titlePrefix: String
let title: String
let titleAction: ((UIView) -> Void)?
@ -26,10 +30,13 @@ final class TextProcessingTextAreaComponent: Component {
let text: TextWithEntities?
let loadingStateMeasuringText: String?
let textCorrectionRanges: [Range<Int>]
let present: (ViewController, Any?) -> Void
let rootViewForTextSelection: () -> UIView?
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
titlePrefix: String,
title: String,
titleAction: ((UIView) -> Void)?,
@ -38,10 +45,13 @@ final class TextProcessingTextAreaComponent: Component {
emojify: (value: Bool, toggle: () -> Void)?,
text: TextWithEntities?,
loadingStateMeasuringText: String?,
textCorrectionRanges: [Range<Int>]
textCorrectionRanges: [Range<Int>],
present: @escaping (ViewController, Any?) -> Void,
rootViewForTextSelection: @escaping () -> UIView?
) {
self.context = context
self.theme = theme
self.strings = strings
self.titlePrefix = titlePrefix
self.isExpanded = isExpanded
self.copyAction = copyAction
@ -51,6 +61,8 @@ final class TextProcessingTextAreaComponent: Component {
self.text = text
self.loadingStateMeasuringText = loadingStateMeasuringText
self.textCorrectionRanges = textCorrectionRanges
self.present = present
self.rootViewForTextSelection = rootViewForTextSelection
}
static func ==(lhs: TextProcessingTextAreaComponent, rhs: TextProcessingTextAreaComponent) -> Bool {
@ -60,6 +72,9 @@ final class TextProcessingTextAreaComponent: Component {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.titlePrefix != rhs.titlePrefix {
return false
}
@ -115,15 +130,29 @@ final class TextProcessingTextAreaComponent: Component {
private let measureLoadingText = ComponentView<Empty>()
private var shimmerEffectNode: ShimmerEffectNode?
private var textSelectionNode: TextSelectionNode?
private let textSelectionContainer: UIView
private let textSelectionKnobContainer: UIView
private let textSelectionKnobSurface: UIView
private var currentSpeechHolder: SpeechSynthesizerHolder?
override init(frame: CGRect) {
self.textContainer = UIView()
self.textContainer.clipsToBounds = true
self.textSelectionContainer = UIView()
self.textSelectionContainer.isUserInteractionEnabled = false
self.textSelectionKnobContainer = UIView()
self.textSelectionKnobSurface = UIView()
self.textSelectionKnobContainer.addSubview(self.textSelectionKnobSurface)
self.titleButton = HighlightTrackingButton()
super.init(frame: frame)
self.addSubview(self.textContainer)
self.addSubview(self.textSelectionContainer)
self.addSubview(self.titleButton)
self.titleButton.highligthedChanged = { [weak self] highighed in
@ -459,6 +488,96 @@ final class TextProcessingTextAreaComponent: Component {
}
}
if component.text != nil, component.isExpanded?.value ?? true, let textView = self.text.view as? MultilineTextWithEntitiesComponent.View {
let textSelectionNode: TextSelectionNode
if let current = self.textSelectionNode {
textSelectionNode = current
} else {
textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: component.theme.list.itemAccentColor.withMultipliedAlpha(0.5), knob: component.theme.list.itemAccentColor, isDark: component.theme.overallDarkAppearance), strings: component.strings, textNodeOrView: .node(textView.textNode), updateIsActive: { [weak self] value in
guard let self else {
return
}
let _ = self
}, present: { [weak self] c, a in
guard let self, let component = self.component else {
return
}
component.present(c, a)
}, rootView: { [weak self] in
guard let self, let component = self.component else {
return nil
}
return component.rootViewForTextSelection()
}, externalKnobSurface: self.textSelectionKnobSurface, performAction: { [weak self] text, action in
guard let self, let component = self.component else {
return
}
switch action {
case .copy:
storeAttributedTextInPasteboard(text)
case .share:
let shareController = component.context.sharedContext.makeShareController(context: component.context, subject: .text(text.string), forceExternal: true, shareStory: nil, enqueued: nil, actionCompleted: nil)
component.present(shareController, nil)
case .lookup:
let controller = UIReferenceLibraryViewController(term: text.string)
if let window = self.window {
controller.popoverPresentationController?.sourceView = window
controller.popoverPresentationController?.sourceRect = CGRect(origin: CGPoint(x: window.bounds.width / 2.0, y: window.bounds.size.height - 1.0), size: CGSize(width: 1.0, height: 1.0))
window.rootViewController?.present(controller, animated: true)
}
case .speak:
if let speechHolder = speakText(context: component.context, text: text.string) {
speechHolder.completion = { [weak self, weak speechHolder] in
guard let self else {
return
}
if self.currentSpeechHolder === speechHolder {
self.currentSpeechHolder = nil
}
}
self.currentSpeechHolder = speechHolder
}
case .translate:
break
case .quote:
break
}
})
textSelectionNode.enableLookup = true
textSelectionNode.enableTranslate = false
textSelectionNode.menuSkipCoordnateConversion = false
textSelectionNode.canBeginSelection = { _ in
return true
}
self.textSelectionNode = textSelectionNode
self.textSelectionContainer.insertSubview(self.textSelectionKnobContainer, at: 0)
self.textContainer.insertSubview(textSelectionNode.highlightAreaNode.view, belowSubview: textView)
self.textContainer.insertSubview(textSelectionNode.view, aboveSubview: textView)
}
textSelectionNode.frame = textView.frame
textSelectionNode.highlightAreaNode.frame = textView.frame
self.textSelectionKnobSurface.frame = textView.frame
} else {
for subview in Array(self.textSelectionContainer.subviews) {
subview.removeFromSuperview()
}
if self.textSelectionKnobContainer.superview != nil {
self.textSelectionKnobContainer.removeFromSuperview()
}
if let textSelectionNode = self.textSelectionNode {
self.textSelectionNode = nil
textSelectionNode.view.removeFromSuperview()
if textSelectionNode.highlightAreaNode.view.superview != nil {
textSelectionNode.highlightAreaNode.view.removeFromSuperview()
}
}
}
if component.text == nil {
let shimmerEffectNode: ShimmerEffectNode
if let current = self.shimmerEffectNode {
@ -568,6 +687,7 @@ final class TextProcessingTextAreaComponent: Component {
}
transition.setFrame(view: self.textContainer, frame: textContainerFrame)
transition.setFrame(view: self.textSelectionContainer, frame: textContainerFrame)
contentHeight += textContainerFrame.height
contentHeight += bottomInset

View file

@ -83,6 +83,8 @@ final class TextProcessingTranslateContentComponent: Component {
let mode: Mode
let copyAction: (() -> Void)?
let displayLanguageSelectionMenu: (UIView, String, TelegramComposeAIMessageMode.StyleId, Bool, @escaping (String, TelegramComposeAIMessageMode.StyleId) -> Void) -> Void
let present: (ViewController, Any?) -> Void
let rootViewForTextSelection: () -> UIView?
init(
context: AccountContext,
@ -93,7 +95,9 @@ final class TextProcessingTranslateContentComponent: Component {
inputText: TextWithEntities,
mode: Mode,
copyAction: (() -> Void)?,
displayLanguageSelectionMenu: @escaping (UIView, String, TelegramComposeAIMessageMode.StyleId, Bool, @escaping (String, TelegramComposeAIMessageMode.StyleId) -> Void) -> Void
displayLanguageSelectionMenu: @escaping (UIView, String, TelegramComposeAIMessageMode.StyleId, Bool, @escaping (String, TelegramComposeAIMessageMode.StyleId) -> Void) -> Void,
present: @escaping (ViewController, Any?) -> Void,
rootViewForTextSelection: @escaping () -> UIView?
) {
self.context = context
self.theme = theme
@ -104,6 +108,8 @@ final class TextProcessingTranslateContentComponent: Component {
self.mode = mode
self.copyAction = copyAction
self.displayLanguageSelectionMenu = displayLanguageSelectionMenu
self.present = present
self.rootViewForTextSelection = rootViewForTextSelection
}
static func ==(lhs: TextProcessingTranslateContentComponent, rhs: TextProcessingTranslateContentComponent) -> Bool {
@ -354,6 +360,7 @@ final class TextProcessingTranslateContentComponent: Component {
component: AnyComponent(TextProcessingTextAreaComponent(
context: component.context,
theme: component.theme,
strings: component.strings,
titlePrefix: fromPrefix,
title: localizedLanguageName(strings: component.strings, language: component.externalState.sourceLanguage ?? ""),
titleAction: nil,
@ -373,7 +380,9 @@ final class TextProcessingTranslateContentComponent: Component {
emojify: nil,
text: component.inputText,
loadingStateMeasuringText: nil,
textCorrectionRanges: []
textCorrectionRanges: [],
present: component.present,
rootViewForTextSelection: component.rootViewForTextSelection
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height)
@ -396,6 +405,7 @@ final class TextProcessingTranslateContentComponent: Component {
component: AnyComponent(TextProcessingTextAreaComponent(
context: component.context,
theme: component.theme,
strings: component.strings,
titlePrefix: toPrefix,
title: toTitle,
titleAction: component.mode == .translate ? { [weak self] sourceView in
@ -441,7 +451,9 @@ final class TextProcessingTranslateContentComponent: Component {
) : nil,
text: component.externalState.result?.text,
loadingStateMeasuringText: component.inputText.text,
textCorrectionRanges: component.mode == .fix ? (component.externalState.result?.textCorrectionRanges ?? []) : []
textCorrectionRanges: component.mode == .fix ? (component.externalState.result?.textCorrectionRanges ?? []) : [],
present: component.present,
rootViewForTextSelection: component.rootViewForTextSelection
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height)

View file

@ -216,15 +216,56 @@ public enum TextSelectionAction: Equatable {
}
public final class TextSelectionNode: ASDisplayNode {
public enum TextNodeOrView {
case node(TextNodeProtocol)
case view(TextView)
var view: UIView {
switch self {
case let .node(node):
return node.view
case let .view(view):
return view
}
}
var currentText: NSAttributedString? {
switch self {
case let .node(node):
return node.currentText
case let .view(view):
return view.cachedLayout?.attributedString
}
}
func attributesAtPoint(_ point: CGPoint, orNearest: Bool = false) -> (Int, [NSAttributedString.Key: Any])? {
switch self {
case let .node(node):
return node.attributesAtPoint(point, orNearest: orNearest)
case let .view(view):
return view.attributesAtPoint(point, orNearest: orNearest)
}
}
func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? {
switch self {
case let .node(node):
return node.textRangeRects(in: range)
case let .view(view):
return view.cachedLayout?.rangeRects(in: range)
}
}
}
private let theme: TextSelectionTheme
private let strings: PresentationStrings
private let textNode: TextNodeProtocol
private let textNodeOrView: TextNodeOrView
private let updateIsActive: (Bool) -> Void
public var canBeginSelection: (CGPoint) -> Bool = { _ in true }
public var updateRange: ((NSRange?) -> Void)?
public var presentMenu: ((UIView, CGPoint, [ContextMenuAction]) -> Void)?
private let present: (ViewController, Any?) -> Void
private let rootNode: () -> ASDisplayNode?
private let rootView: () -> UIView?
private let performAction: (NSAttributedString, TextSelectionAction) -> Void
private var highlightOverlay: LinkHighlightingNode?
private let leftKnob: ASImageNode
@ -252,13 +293,13 @@ public final class TextSelectionNode: ASDisplayNode {
private weak var contextMenu: ContextMenuController?
public init(theme: TextSelectionTheme, strings: PresentationStrings, textNode: TextNodeProtocol, updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootNode: @escaping () -> ASDisplayNode?, externalKnobSurface: UIView? = nil, performAction: @escaping (NSAttributedString, TextSelectionAction) -> Void) {
public init(theme: TextSelectionTheme, strings: PresentationStrings, textNodeOrView: TextNodeOrView, updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootView: @escaping () -> UIView?, externalKnobSurface: UIView? = nil, performAction: @escaping (NSAttributedString, TextSelectionAction) -> Void) {
self.theme = theme
self.strings = strings
self.textNode = textNode
self.textNodeOrView = textNodeOrView
self.updateIsActive = updateIsActive
self.present = present
self.rootNode = rootNode
self.rootView = rootView
self.performAction = performAction
self.leftKnob = ASImageNode()
self.leftKnob.isUserInteractionEnabled = false
@ -306,8 +347,8 @@ public final class TextSelectionNode: ASDisplayNode {
return
}
let mappedPoint = strongSelf.view.convert(point, to: strongSelf.textNode.view)
if let stringIndex = strongSelf.textNode.attributesAtPoint(mappedPoint, orNearest: true)?.0 {
let mappedPoint = strongSelf.view.convert(point, to: strongSelf.textNodeOrView.view)
if let stringIndex = strongSelf.textNodeOrView.attributesAtPoint(mappedPoint, orNearest: true)?.0 {
var updatedLeft = currentRange.0
var updatedRight = currentRange.1
switch knob {
@ -335,15 +376,15 @@ public final class TextSelectionNode: ASDisplayNode {
strongSelf.displayMenu()
}
recognizer.beginSelection = { [weak self] point in
guard let strongSelf = self, let attributedString = strongSelf.textNode.currentText else {
guard let strongSelf = self, let attributedString = strongSelf.textNodeOrView.currentText else {
return
}
strongSelf.dismissSelection()
let mappedPoint = strongSelf.view.convert(point, to: strongSelf.textNode.view)
let mappedPoint = strongSelf.view.convert(point, to: strongSelf.textNodeOrView.view)
var resultRange: NSRange?
if let stringIndex = strongSelf.textNode.attributesAtPoint(mappedPoint, orNearest: false)?.0 {
if let stringIndex = strongSelf.textNodeOrView.attributesAtPoint(mappedPoint, orNearest: false)?.0 {
let string = attributedString.string as NSString
let inputRange = CFRangeMake(0, string.length)
@ -398,7 +439,7 @@ public final class TextSelectionNode: ASDisplayNode {
}
public func pretendInitiateSelection() {
guard let attributedString = self.textNode.currentText else {
guard let attributedString = self.textNodeOrView.currentText else {
return
}
@ -432,7 +473,7 @@ public final class TextSelectionNode: ASDisplayNode {
}
public func pretendExtendSelection(to index: Int) {
guard let endRangeRect = self.textNode.textRangeRects(in: NSRange(location: index, length: 1))?.rects.first else {
guard let endRangeRect = self.textNodeOrView.textRangeRects(in: NSRange(location: index, length: 1))?.rects.first else {
return
}
let startPoint = self.rightKnob.frame.center
@ -449,7 +490,7 @@ public final class TextSelectionNode: ASDisplayNode {
}
public func setSelection(range: NSRange, displayMenu: Bool) {
guard let attributedString = self.textNode.currentText else {
guard let attributedString = self.textNodeOrView.currentText else {
return
}
let range = self.convertSelectionFromOriginalText(attributedString: attributedString, range: range)
@ -561,7 +602,7 @@ public final class TextSelectionNode: ASDisplayNode {
}
public func getSelection() -> NSRange? {
guard let currentRange = self.currentRange, let attributedString = self.textNode.currentText else {
guard let currentRange = self.currentRange, let attributedString = self.textNodeOrView.currentText else {
return nil
}
let range = NSRange(location: min(currentRange.0, currentRange.1), length: max(currentRange.0, currentRange.1) - min(currentRange.0, currentRange.1))
@ -574,7 +615,7 @@ public final class TextSelectionNode: ASDisplayNode {
var rects: (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)?
if let range {
if var rectsValue = self.textNode.textRangeRects(in: range) {
if var rectsValue = self.textNodeOrView.textRangeRects(in: range) {
var rectList = rectsValue.rects
if rectList.count > 1 {
for i in 0 ..< rectList.count - 1 {
@ -683,7 +724,7 @@ public final class TextSelectionNode: ASDisplayNode {
}
private func displayMenu() {
guard let currentRects = self.currentRects, !currentRects.isEmpty, let currentRange = self.currentRange, let attributedString = self.textNode.currentText else {
guard let currentRects = self.currentRects, !currentRects.isEmpty, let currentRange = self.currentRange, let attributedString = self.textNodeOrView.currentText else {
return
}
let range = NSRange(location: min(currentRange.0, currentRange.1), length: max(currentRange.0, currentRange.1) - min(currentRange.0, currentRange.1))
@ -778,15 +819,15 @@ public final class TextSelectionNode: ASDisplayNode {
return true
}
self.contextMenu = contextMenu
self.present(contextMenu, ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in
guard let strongSelf = self, let rootNode = strongSelf.rootNode() else {
self.present(contextMenu, ContextMenuControllerPresentationArguments(sourceViewAndRect: { [weak self] in
guard let strongSelf = self, let rootView = strongSelf.rootView() else {
return nil
}
if strongSelf.menuSkipCoordnateConversion {
return (strongSelf, strongSelf.view.convert(completeRect, to: rootNode.view), rootNode, rootNode.bounds)
return (strongSelf.view, strongSelf.view.convert(completeRect, to: rootView), rootView, rootView.bounds)
} else {
return (strongSelf, completeRect, rootNode, rootNode.bounds)
return (strongSelf.view, completeRect, rootView, rootView.bounds)
}
}, bounce: false))
}