mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
Add instant view arbitrary text selection
This commit is contained in:
parent
cfd5419005
commit
a941fe6abf
@ -17,6 +17,7 @@ import ShareController
|
||||
import OpenInExternalAppUI
|
||||
import AppBundle
|
||||
import LocalizedPeerData
|
||||
import TextSelectionNode
|
||||
|
||||
private let deleteImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/MessageSelectionTrash"), color: .white)
|
||||
private let actionImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/MessageSelectionAction"), color: .white)
|
||||
@ -119,6 +120,7 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll
|
||||
private let scrollNode: ASScrollNode
|
||||
|
||||
private let textNode: ImmediateTextNode
|
||||
private var textSelectionNode: TextSelectionNode?
|
||||
private let authorNameNode: ASTextNode
|
||||
private let dateNode: ASTextNode
|
||||
private let backwardButton: HighlightableButtonNode
|
||||
@ -360,6 +362,23 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll
|
||||
}
|
||||
}
|
||||
self.statusButtonNode.addTarget(self, action: #selector(self.statusPressed), forControlEvents: .touchUpInside)
|
||||
|
||||
let accentColor = presentationData.theme.list.itemAccentColor
|
||||
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: accentColor.withAlphaComponent(0.2), knob: accentColor), strings: presentationData.strings, textNode: self.textNode, updateIsActive: { [weak self] value in
|
||||
// self?.updateIsTextSelectionActive?(value)
|
||||
}, present: { [weak self] c, a in
|
||||
// self?.item?.controllerInteraction.presentGlobalOverlayController(c, a)
|
||||
}, rootNode: self, performAction: { [weak self] text, action in
|
||||
// guard let strongSelf = self, let item = strongSelf.item else {
|
||||
// return
|
||||
// }
|
||||
// item.controllerInteraction.performTextSelectionAction(item.message.stableId, text, action)
|
||||
})
|
||||
self.textSelectionNode = textSelectionNode
|
||||
self.scrollNode.addSubnode(textSelectionNode)
|
||||
self.scrollNode.insertSubnode(textSelectionNode.highlightAreaNode, belowSubnode: self.textNode)
|
||||
textSelectionNode.frame = self.textNode.frame
|
||||
textSelectionNode.highlightAreaNode.frame = self.textNode.frame
|
||||
}
|
||||
|
||||
deinit {
|
||||
@ -589,6 +608,12 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll
|
||||
textFrame = CGRect(origin: CGPoint(x: sideInset, y: topInset + textOffset), size: textSize)
|
||||
if self.textNode.frame != textFrame {
|
||||
self.textNode.frame = textFrame
|
||||
|
||||
if let textSelectionNode = self.textSelectionNode {
|
||||
textSelectionNode.frame = textFrame
|
||||
textSelectionNode.highlightAreaNode.frame = textFrame
|
||||
textSelectionNode.updateLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -47,7 +47,8 @@ final class InstantPageControllerNode: ASDisplayNode, UIScrollViewDelegate {
|
||||
private let scrollNodeHeader: ASDisplayNode
|
||||
private let scrollNodeFooter: ASDisplayNode
|
||||
private var linkHighlightingNode: LinkHighlightingNode?
|
||||
private var textSelectionNode: LinkHighlightingNode?
|
||||
private var textSelectionNode: InstantPageTextSelectionNode?
|
||||
|
||||
private var settingsNode: InstantPageSettingsNode?
|
||||
private var settingsDimNode: ASDisplayNode?
|
||||
|
||||
@ -115,7 +116,7 @@ final class InstantPageControllerNode: ASDisplayNode, UIScrollViewDelegate {
|
||||
|
||||
self.scrollNodeFooter = ASDisplayNode()
|
||||
self.scrollNodeFooter.backgroundColor = .black
|
||||
|
||||
|
||||
super.init()
|
||||
|
||||
self.setViewBlock({
|
||||
@ -155,6 +156,31 @@ final class InstantPageControllerNode: ASDisplayNode, UIScrollViewDelegate {
|
||||
|> deliverOnMainQueue).start(next: { [weak self] value in
|
||||
self?.navigationBar.setLoadProgress(value)
|
||||
}))
|
||||
|
||||
let selectionNode = InstantPageTextSelectionNode(theme: InstantPageTextSelectionTheme(selection: presentationTheme.chat.message.incoming.textSelectionColor, knob: presentationTheme.chat.message.incoming.textSelectionKnobColor), strings: strings, textItemAtLocation: { [weak self] point in
|
||||
if let strongSelf = self {
|
||||
return strongSelf.textItemAtLocation(point)
|
||||
}
|
||||
return nil
|
||||
}, updateIsActive: { active in
|
||||
|
||||
}, present: { [weak self] controller, args in
|
||||
if let strongSelf = self {
|
||||
strongSelf.present(controller, args)
|
||||
}
|
||||
}, rootNode: self, performAction: { text, action in
|
||||
// let controller = ContextMenuController(actions: [ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.strings.Conversation_ContextMenuCopy), action: {
|
||||
// UIPasteboard.general.string = text
|
||||
// }), ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuShare, accessibilityLabel: self.strings.Conversation_ContextMenuShare), action: { [weak self] in
|
||||
// if let strongSelf = self, let webPage = strongSelf.webPage, case let .Loaded(content) = webPage.content {
|
||||
// strongSelf.present(ShareController(context: strongSelf.context, subject: .quote(text: text, url: content.url)), nil)
|
||||
// }
|
||||
// })])
|
||||
})
|
||||
self.scrollNode.addSubnode(selectionNode)
|
||||
self.textSelectionNode = selectionNode
|
||||
|
||||
self.scrollNode.addSubnode(selectionNode.highlightAreaNode)
|
||||
}
|
||||
|
||||
deinit {
|
||||
@ -458,6 +484,7 @@ final class InstantPageControllerNode: ASDisplayNode, UIScrollViewDelegate {
|
||||
|
||||
self.scrollNode.view.contentSize = currentLayout.contentSize
|
||||
self.scrollNodeFooter.frame = CGRect(origin: CGPoint(x: 0.0, y: currentLayout.contentSize.height), size: CGSize(width: containerLayout.size.width, height: 2000.0))
|
||||
self.textSelectionNode?.frame = CGRect(origin: CGPoint(), size: self.scrollNode.view.contentSize)
|
||||
}
|
||||
|
||||
func updateVisibleItems(visibleBounds: CGRect, animated: Bool = false) {
|
||||
@ -629,6 +656,7 @@ final class InstantPageControllerNode: ASDisplayNode, UIScrollViewDelegate {
|
||||
if effectiveContentHeight != self.scrollNode.view.contentSize.height {
|
||||
transition.animateView {
|
||||
self.scrollNode.view.contentSize = CGSize(width: currentLayout.contentSize.width, height: effectiveContentHeight)
|
||||
self.textSelectionNode?.frame = CGRect(origin: CGPoint(), size: self.scrollNode.view.contentSize)
|
||||
}
|
||||
let previousFrame = self.scrollNodeFooter.frame
|
||||
self.scrollNodeFooter.frame = CGRect(origin: CGPoint(x: 0.0, y: effectiveContentHeight), size: CGSize(width: previousFrame.width, height: 2000.0))
|
||||
@ -943,12 +971,12 @@ final class InstantPageControllerNode: ASDisplayNode, UIScrollViewDelegate {
|
||||
])])
|
||||
self.present(actionSheet, nil)
|
||||
} else if let (item, parentOffset) = self.textItemAtLocation(location) {
|
||||
let textFrame = item.frame
|
||||
var itemRects = item.lineRects()
|
||||
for i in 0 ..< itemRects.count {
|
||||
itemRects[i] = itemRects[i].offsetBy(dx: parentOffset.x + textFrame.minX, dy: parentOffset.y + textFrame.minY).insetBy(dx: -2.0, dy: -2.0)
|
||||
}
|
||||
self.updateTextSelectionRects(itemRects, text: item.plainText())
|
||||
// let textFrame = item.frame
|
||||
// var itemRects = item.lineRects()
|
||||
// for i in 0 ..< itemRects.count {
|
||||
// itemRects[i] = itemRects[i].offsetBy(dx: parentOffset.x + textFrame.minX, dy: parentOffset.y + textFrame.minY).insetBy(dx: -2.0, dy: -2.0)
|
||||
// }
|
||||
// self.updateTextSelectionRects(itemRects, text: item.plainText())
|
||||
}
|
||||
default:
|
||||
break
|
||||
@ -959,51 +987,6 @@ final class InstantPageControllerNode: ASDisplayNode, UIScrollViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateTextSelectionRects(_ rects: [CGRect], text: String?) {
|
||||
if let text = text, !rects.isEmpty {
|
||||
let textSelectionNode: LinkHighlightingNode
|
||||
if let current = self.textSelectionNode {
|
||||
textSelectionNode = current
|
||||
} else {
|
||||
textSelectionNode = LinkHighlightingNode(color: UIColor.lightGray.withAlphaComponent(0.4))
|
||||
textSelectionNode.isUserInteractionEnabled = false
|
||||
self.textSelectionNode = textSelectionNode
|
||||
self.scrollNode.addSubnode(textSelectionNode)
|
||||
}
|
||||
textSelectionNode.frame = CGRect(origin: CGPoint(), size: self.scrollNode.bounds.size)
|
||||
textSelectionNode.updateRects(rects)
|
||||
|
||||
var coveringRect = rects[0]
|
||||
for i in 1 ..< rects.count {
|
||||
coveringRect = coveringRect.union(rects[i])
|
||||
}
|
||||
|
||||
let controller = ContextMenuController(actions: [ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.strings.Conversation_ContextMenuCopy), action: {
|
||||
UIPasteboard.general.string = text
|
||||
}), ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuShare, accessibilityLabel: self.strings.Conversation_ContextMenuShare), action: { [weak self] in
|
||||
if let strongSelf = self, let webPage = strongSelf.webPage, case let .Loaded(content) = webPage.content {
|
||||
strongSelf.present(ShareController(context: strongSelf.context, subject: .quote(text: text, url: content.url)), nil)
|
||||
}
|
||||
})])
|
||||
controller.dismissed = { [weak self] in
|
||||
self?.updateTextSelectionRects([], text: nil)
|
||||
}
|
||||
self.present(controller, ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in
|
||||
if let strongSelf = self {
|
||||
return (strongSelf.scrollNode, coveringRect.insetBy(dx: -3.0, dy: -3.0), strongSelf, strongSelf.bounds)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}))
|
||||
textSelectionNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18)
|
||||
} else if let textSelectionNode = self.textSelectionNode {
|
||||
self.textSelectionNode = nil
|
||||
textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in
|
||||
textSelectionNode?.removeFromSupernode()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func findAnchorItem(_ anchor: String, items: [InstantPageItem]) -> (InstantPageItem, CGFloat, Bool, [InstantPageDetailsItem])? {
|
||||
for item in items {
|
||||
if let item = item as? InstantPageAnchorItem, item.anchor == anchor {
|
||||
|
@ -45,6 +45,18 @@ struct InstantPageTextAnchorItem {
|
||||
let empty: Bool
|
||||
}
|
||||
|
||||
public struct InstantPageTextRangeRectEdge: Equatable {
|
||||
public var x: CGFloat
|
||||
public var y: CGFloat
|
||||
public var height: CGFloat
|
||||
|
||||
public init(x: CGFloat, y: CGFloat, height: CGFloat) {
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.height = height
|
||||
}
|
||||
}
|
||||
|
||||
final class InstantPageTextLine {
|
||||
let line: CTLine
|
||||
let range: NSRange
|
||||
@ -180,7 +192,7 @@ final class InstantPageTextItem: InstantPageItem {
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
private func attributesAtPoint(_ point: CGPoint) -> (Int, [NSAttributedString.Key: Any])? {
|
||||
func attributesAtPoint(_ point: CGPoint) -> (Int, [NSAttributedString.Key: Any])? {
|
||||
let transformedPoint = CGPoint(x: point.x, y: point.y)
|
||||
let boundsWidth = self.frame.width
|
||||
for i in 0 ..< self.lines.count {
|
||||
@ -265,6 +277,88 @@ final class InstantPageTextItem: InstantPageItem {
|
||||
return nil
|
||||
}
|
||||
|
||||
func rangeRects(in range: NSRange) -> (rects: [CGRect], start: InstantPageTextRangeRectEdge?, end: InstantPageTextRangeRectEdge?)? {
|
||||
guard range.length != 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let boundsWidth = self.frame.width
|
||||
|
||||
var rects: [(CGRect, CGRect)] = []
|
||||
var startEdge: InstantPageTextRangeRectEdge?
|
||||
var endEdge: InstantPageTextRangeRectEdge?
|
||||
for i in 0 ..< self.lines.count {
|
||||
let line = self.lines[i]
|
||||
let lineRange = NSIntersectionRange(range, line.range)
|
||||
if lineRange.length != 0 {
|
||||
var leftOffset: CGFloat = 0.0
|
||||
if lineRange.location != line.range.location || line.isRTL {
|
||||
leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil))
|
||||
}
|
||||
var rightOffset: CGFloat = line.frame.width
|
||||
if lineRange.location + lineRange.length != line.range.upperBound || line.isRTL {
|
||||
var secondaryOffset: CGFloat = 0.0
|
||||
let rawOffset = CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, &secondaryOffset)
|
||||
rightOffset = ceil(rawOffset)
|
||||
if !rawOffset.isEqual(to: secondaryOffset) {
|
||||
rightOffset = ceil(secondaryOffset)
|
||||
}
|
||||
}
|
||||
|
||||
var lineFrame = line.frame
|
||||
for imageItem in line.imageItems {
|
||||
if imageItem.frame.minY < lineFrame.minY {
|
||||
let delta = lineFrame.minY - imageItem.frame.minY - 2.0
|
||||
lineFrame = CGRect(x: lineFrame.minX, y: lineFrame.minY - delta, width: lineFrame.width, height: lineFrame.height + delta)
|
||||
}
|
||||
if imageItem.frame.maxY > lineFrame.maxY {
|
||||
let delta = imageItem.frame.maxY - lineFrame.maxY - 2.0
|
||||
lineFrame = CGRect(x: lineFrame.minX, y: lineFrame.minY, width: lineFrame.width, height: lineFrame.height + delta)
|
||||
}
|
||||
}
|
||||
lineFrame = lineFrame.insetBy(dx: 0.0, dy: -4.0)
|
||||
if self.alignment == .center {
|
||||
lineFrame.origin.x = floor((boundsWidth - lineFrame.size.width) / 2.0)
|
||||
} else if self.alignment == .right {
|
||||
lineFrame.origin.x = boundsWidth - lineFrame.size.width
|
||||
} else if self.alignment == .natural && self.rtlLineIndices.contains(i) {
|
||||
lineFrame.origin.x = boundsWidth - lineFrame.size.width
|
||||
}
|
||||
|
||||
let width = max(0.0, abs(rightOffset - leftOffset))
|
||||
|
||||
if line.range.contains(range.lowerBound) {
|
||||
let offsetX = floor(CTLineGetOffsetForStringIndex(line.line, range.lowerBound, nil))
|
||||
startEdge = InstantPageTextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height)
|
||||
}
|
||||
if line.range.contains(range.upperBound - 1) {
|
||||
let offsetX: CGFloat
|
||||
if line.range.upperBound == range.upperBound {
|
||||
offsetX = lineFrame.maxX
|
||||
} else {
|
||||
var secondaryOffset: CGFloat = 0.0
|
||||
let primaryOffset = floor(CTLineGetOffsetForStringIndex(line.line, range.upperBound - 1, &secondaryOffset))
|
||||
secondaryOffset = floor(secondaryOffset)
|
||||
let nextOffet = floor(CTLineGetOffsetForStringIndex(line.line, range.upperBound, &secondaryOffset))
|
||||
|
||||
if primaryOffset != secondaryOffset {
|
||||
offsetX = secondaryOffset
|
||||
} else {
|
||||
offsetX = nextOffet
|
||||
}
|
||||
}
|
||||
endEdge = InstantPageTextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height)
|
||||
}
|
||||
|
||||
rects.append((lineFrame, CGRect(origin: CGPoint(x: lineFrame.minX + min(leftOffset, rightOffset), y: lineFrame.minY), size: CGSize(width: width, height: lineFrame.size.height))))
|
||||
}
|
||||
}
|
||||
if !rects.isEmpty, let startEdge = startEdge, let endEdge = endEdge {
|
||||
return (rects.map { $1 }, startEdge, endEdge)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lineRects() -> [CGRect] {
|
||||
let boundsWidth = self.frame.width
|
||||
var rects: [CGRect] = []
|
||||
|
@ -0,0 +1,522 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UIKit.UIGestureRecognizerSubclass
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
import TelegramPresentationData
|
||||
|
||||
private func findScrollView(view: UIView?) -> UIScrollView? {
|
||||
if let view = view {
|
||||
if let view = view as? UIScrollView {
|
||||
return view
|
||||
}
|
||||
return findScrollView(view: view.superview)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelScrollViewGestures(view: UIView?) {
|
||||
if let view = view {
|
||||
if let gestureRecognizers = view.gestureRecognizers {
|
||||
for recognizer in gestureRecognizers {
|
||||
if let recognizer = recognizer as? UIPanGestureRecognizer {
|
||||
switch recognizer.state {
|
||||
case .began, .possible:
|
||||
recognizer.state = .ended
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cancelScrollViewGestures(view: view.superview)
|
||||
}
|
||||
}
|
||||
|
||||
private func generateKnobImage(color: UIColor, diameter: CGFloat, inverted: Bool = false) -> UIImage? {
|
||||
let f: (CGSize, CGContext) -> Void = { size, context in
|
||||
context.clear(CGRect(origin: CGPoint(), size: size))
|
||||
context.setFillColor(color.cgColor)
|
||||
context.fill(CGRect(origin: CGPoint(x: (size.width - 2.0) / 2.0, y: size.width / 2.0), size: CGSize(width: 2.0, height: size.height - size.width / 2.0 - 1.0)))
|
||||
context.fillEllipse(in: CGRect(origin: CGPoint(x: floor((size.width - diameter) / 2.0), y: floor((size.width - diameter) / 2.0)), size: CGSize(width: diameter, height: diameter)))
|
||||
context.fillEllipse(in: CGRect(origin: CGPoint(x: (size.width - 2.0) / 2.0, y: size.width + 2.0), size: CGSize(width: 2.0, height: 2.0)))
|
||||
}
|
||||
let size = CGSize(width: 12.0, height: 12.0 + 2.0 + 2.0)
|
||||
if inverted {
|
||||
return generateImage(size, contextGenerator: f)?.stretchableImage(withLeftCapWidth: Int(size.width / 2.0), topCapHeight: Int(size.height) - (Int(size.width) + 1))
|
||||
} else {
|
||||
return generateImage(size, rotatedContext: f)?.stretchableImage(withLeftCapWidth: Int(size.width / 2.0), topCapHeight: Int(size.width) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
public final class InstantPageTextSelectionTheme {
|
||||
public let selection: UIColor
|
||||
public let knob: UIColor
|
||||
public let knobDiameter: CGFloat
|
||||
|
||||
public init(selection: UIColor, knob: UIColor, knobDiameter: CGFloat = 12.0) {
|
||||
self.selection = selection
|
||||
self.knob = knob
|
||||
self.knobDiameter = knobDiameter
|
||||
}
|
||||
}
|
||||
|
||||
private enum Knob {
|
||||
case left
|
||||
case right
|
||||
}
|
||||
|
||||
private final class InstantPageTextSelectionGetureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate {
|
||||
private var longTapTimer: Timer?
|
||||
private var movingKnob: (Knob, CGPoint, CGPoint)?
|
||||
private var currentLocation: CGPoint?
|
||||
|
||||
var beginSelection: ((CGPoint) -> Void)?
|
||||
var knobAtPoint: ((CGPoint) -> (Knob, CGPoint)?)?
|
||||
var moveKnob: ((Knob, CGPoint) -> Void)?
|
||||
var finishedMovingKnob: (() -> Void)?
|
||||
var clearSelection: (() -> Void)?
|
||||
|
||||
override init(target: Any?, action: Selector?) {
|
||||
super.init(target: nil, action: nil)
|
||||
|
||||
self.delegate = self
|
||||
}
|
||||
|
||||
override public func reset() {
|
||||
super.reset()
|
||||
|
||||
self.longTapTimer?.invalidate()
|
||||
self.longTapTimer = nil
|
||||
|
||||
self.movingKnob = nil
|
||||
self.currentLocation = nil
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
|
||||
let currentLocation = touches.first?.location(in: self.view)
|
||||
self.currentLocation = currentLocation
|
||||
|
||||
if let currentLocation = currentLocation {
|
||||
if let (knob, knobPosition) = self.knobAtPoint?(currentLocation) {
|
||||
self.movingKnob = (knob, knobPosition, currentLocation)
|
||||
cancelScrollViewGestures(view: self.view?.superview)
|
||||
self.state = .began
|
||||
} else if self.longTapTimer == nil {
|
||||
final class TimerTarget: NSObject {
|
||||
let f: () -> Void
|
||||
|
||||
init(_ f: @escaping () -> Void) {
|
||||
self.f = f
|
||||
}
|
||||
|
||||
@objc func event() {
|
||||
self.f()
|
||||
}
|
||||
}
|
||||
let longTapTimer = Timer(timeInterval: 0.3, target: TimerTarget({ [weak self] in
|
||||
self?.longTapEvent()
|
||||
}), selector: #selector(TimerTarget.event), userInfo: nil, repeats: false)
|
||||
self.longTapTimer = longTapTimer
|
||||
RunLoop.main.add(longTapTimer, forMode: .common)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesMoved(touches, with: event)
|
||||
|
||||
let currentLocation = touches.first?.location(in: self.view)
|
||||
self.currentLocation = currentLocation
|
||||
|
||||
if let (knob, initialKnobPosition, initialGesturePosition) = self.movingKnob, let currentLocation = currentLocation {
|
||||
self.moveKnob?(knob, CGPoint(x: initialKnobPosition.x + currentLocation.x - initialGesturePosition.x, y: initialKnobPosition.y + currentLocation.y - initialGesturePosition.y))
|
||||
}
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesEnded(touches, with: event)
|
||||
|
||||
if let longTapTimer = self.longTapTimer {
|
||||
self.longTapTimer = nil
|
||||
longTapTimer.invalidate()
|
||||
self.clearSelection?()
|
||||
} else {
|
||||
if let _ = self.currentLocation, let _ = self.movingKnob {
|
||||
self.finishedMovingKnob?()
|
||||
}
|
||||
}
|
||||
self.state = .ended
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesCancelled(touches, with: event)
|
||||
|
||||
self.state = .cancelled
|
||||
}
|
||||
|
||||
private func longTapEvent() {
|
||||
if let currentLocation = self.currentLocation {
|
||||
self.beginSelection?(currentLocation)
|
||||
self.state = .ended
|
||||
}
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@available(iOS 9.0, *)
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive press: UIPress) -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public final class InstantPageTextSelectionNodeView: UIView {
|
||||
var hitTestImpl: ((CGPoint, UIEvent?) -> UIView?)?
|
||||
|
||||
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
return self.hitTestImpl?(point, event)
|
||||
}
|
||||
}
|
||||
|
||||
public enum InstantPageTextSelectionAction {
|
||||
case copy
|
||||
case share
|
||||
case lookup
|
||||
}
|
||||
|
||||
public struct InstantPageTextSelectionItem {
|
||||
let item: InstantPageTextItem
|
||||
let start: Int
|
||||
let end: Int
|
||||
|
||||
var range: NSRange {
|
||||
return NSRange(location: self.start, length: self.end - self.start)
|
||||
}
|
||||
}
|
||||
|
||||
public struct InstantPageTextSelection {
|
||||
let items: [InstantPageTextSelectionItem]
|
||||
}
|
||||
|
||||
final class InstantPageTextSelectionNode: ASDisplayNode {
|
||||
private let theme: InstantPageTextSelectionTheme
|
||||
private let strings: PresentationStrings
|
||||
private let textItemAtLocation: (CGPoint) -> (InstantPageTextItem, CGPoint)?
|
||||
private let updateIsActive: (Bool) -> Void
|
||||
private let present: (ViewController, Any?) -> Void
|
||||
private weak var rootNode: ASDisplayNode?
|
||||
private let performAction: (String, InstantPageTextSelectionAction) -> Void
|
||||
private var highlightOverlay: LinkHighlightingNode?
|
||||
private let leftKnob: ASImageNode
|
||||
private let rightKnob: ASImageNode
|
||||
|
||||
private var currentSelection: InstantPageTextSelection?
|
||||
private var currentRects: [CGRect]?
|
||||
|
||||
public let highlightAreaNode: ASDisplayNode
|
||||
|
||||
private var recognizer: InstantPageTextSelectionGetureRecognizer?
|
||||
private var displayLinkAnimator: DisplayLinkAnimator?
|
||||
|
||||
public init(theme: InstantPageTextSelectionTheme, strings: PresentationStrings, textItemAtLocation: @escaping (CGPoint) -> (InstantPageTextItem, CGPoint)?, updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootNode: ASDisplayNode, performAction: @escaping (String, InstantPageTextSelectionAction) -> Void) {
|
||||
self.theme = theme
|
||||
self.strings = strings
|
||||
self.textItemAtLocation = textItemAtLocation
|
||||
self.updateIsActive = updateIsActive
|
||||
self.present = present
|
||||
self.rootNode = rootNode
|
||||
self.performAction = performAction
|
||||
self.leftKnob = ASImageNode()
|
||||
self.leftKnob.isUserInteractionEnabled = false
|
||||
self.leftKnob.image = generateKnobImage(color: theme.knob, diameter: theme.knobDiameter)
|
||||
self.leftKnob.displaysAsynchronously = false
|
||||
self.leftKnob.displayWithoutProcessing = true
|
||||
self.leftKnob.alpha = 0.0
|
||||
self.rightKnob = ASImageNode()
|
||||
self.rightKnob.isUserInteractionEnabled = false
|
||||
self.rightKnob.image = generateKnobImage(color: theme.knob, diameter: theme.knobDiameter, inverted: true)
|
||||
self.rightKnob.displaysAsynchronously = false
|
||||
self.rightKnob.displayWithoutProcessing = true
|
||||
self.rightKnob.alpha = 0.0
|
||||
|
||||
self.highlightAreaNode = ASDisplayNode()
|
||||
|
||||
super.init()
|
||||
|
||||
self.setViewBlock({
|
||||
return InstantPageTextSelectionNodeView()
|
||||
})
|
||||
|
||||
self.addSubnode(self.leftKnob)
|
||||
self.addSubnode(self.rightKnob)
|
||||
}
|
||||
|
||||
override public func didLoad() {
|
||||
super.didLoad()
|
||||
|
||||
(self.view as? InstantPageTextSelectionNodeView)?.hitTestImpl = { [weak self] point, event in
|
||||
return self?.hitTest(point, with: event)
|
||||
}
|
||||
|
||||
let recognizer = InstantPageTextSelectionGetureRecognizer(target: nil, action: nil)
|
||||
recognizer.knobAtPoint = { [weak self] point in
|
||||
return self?.knobAtPoint(point)
|
||||
}
|
||||
recognizer.moveKnob = { [weak self] knob, point in
|
||||
guard let strongSelf = self, let currentSelection = strongSelf.currentSelection, let currentItem = currentSelection.items.first else {
|
||||
return
|
||||
}
|
||||
|
||||
if let (item, parentOffset) = strongSelf.textItemAtLocation(point) {
|
||||
let mappedPoint = point.offsetBy(dx: -item.frame.minX - parentOffset.x, dy: -item.frame.minY - parentOffset.y)
|
||||
if let stringIndex = item.attributesAtPoint(mappedPoint)?.0 {
|
||||
var updatedLeft = currentItem.start
|
||||
var updatedRight = currentItem.end
|
||||
switch knob {
|
||||
case .left:
|
||||
updatedLeft = stringIndex
|
||||
case .right:
|
||||
updatedRight = stringIndex
|
||||
}
|
||||
if currentItem.start != updatedLeft || currentItem.end != updatedRight {
|
||||
strongSelf.currentSelection = InstantPageTextSelection(items: [InstantPageTextSelectionItem(item: item, start: updatedLeft, end: updatedRight)])
|
||||
strongSelf.updateSelection(selection: strongSelf.currentSelection, animateIn: false)
|
||||
}
|
||||
|
||||
if let scrollView = findScrollView(view: strongSelf.view) {
|
||||
let scrollPoint = strongSelf.view.convert(point, to: scrollView)
|
||||
scrollView.scrollRectToVisible(CGRect(origin: CGPoint(x: scrollPoint.x, y: scrollPoint.y - 30.0), size: CGSize(width: 1.0, height: 60.0)), animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
recognizer.finishedMovingKnob = { [weak self] in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
strongSelf.displayMenu()
|
||||
}
|
||||
recognizer.beginSelection = { [weak self] point in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
|
||||
strongSelf.dismissSelection()
|
||||
|
||||
if let (item, parentOffset) = strongSelf.textItemAtLocation(point) {
|
||||
let mappedPoint = point.offsetBy(dx: -item.frame.minX - parentOffset.x, dy: -item.frame.minY - parentOffset.y)
|
||||
var resultRange: NSRange?
|
||||
if let stringIndex = item.attributesAtPoint(mappedPoint)?.0 {
|
||||
let string = item.attributedString.string as NSString
|
||||
|
||||
let inputRange = CFRangeMake(0, string.length)
|
||||
let flag = UInt(kCFStringTokenizerUnitWord)
|
||||
let locale = CFLocaleCopyCurrent()
|
||||
let tokenizer = CFStringTokenizerCreate(kCFAllocatorDefault, string as CFString, inputRange, flag, locale)
|
||||
var tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer)
|
||||
|
||||
while !tokenType.isEmpty {
|
||||
let currentTokenRange = CFStringTokenizerGetCurrentTokenRange(tokenizer)
|
||||
if currentTokenRange.location <= stringIndex && currentTokenRange.location + currentTokenRange.length > stringIndex {
|
||||
resultRange = NSRange(location: currentTokenRange.location, length: currentTokenRange.length)
|
||||
break
|
||||
}
|
||||
tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer)
|
||||
}
|
||||
|
||||
if resultRange == nil {
|
||||
resultRange = NSRange(location: stringIndex, length: 1)
|
||||
}
|
||||
}
|
||||
|
||||
strongSelf.currentSelection = resultRange.flatMap {
|
||||
InstantPageTextSelection(items: [InstantPageTextSelectionItem(item: item, start: $0.lowerBound, end: $0.upperBound)])
|
||||
}
|
||||
}
|
||||
strongSelf.updateSelection(selection: strongSelf.currentSelection, animateIn: true)
|
||||
strongSelf.displayMenu()
|
||||
strongSelf.updateIsActive(true)
|
||||
}
|
||||
recognizer.clearSelection = { [weak self] in
|
||||
self?.dismissSelection()
|
||||
self?.updateIsActive(false)
|
||||
}
|
||||
self.recognizer = recognizer
|
||||
self.view.addGestureRecognizer(recognizer)
|
||||
}
|
||||
|
||||
public func updateLayout() {
|
||||
if let currentSelection = self.currentSelection {
|
||||
self.updateSelection(selection: currentSelection, animateIn: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelection(selection: InstantPageTextSelection?, animateIn: Bool) {
|
||||
var rects: (rects: [CGRect], start: InstantPageTextRangeRectEdge, end: InstantPageTextRangeRectEdge)?
|
||||
|
||||
if let selection = selection, selection.items.count > 0 {
|
||||
var selectionRects: [CGRect] = []
|
||||
var start: InstantPageTextRangeRectEdge?
|
||||
var end: InstantPageTextRangeRectEdge?
|
||||
|
||||
for i in 0 ..< selection.items.count {
|
||||
let item = selection.items[i]
|
||||
if let (itemRects, itemStart, itemEnd) = item.item.rangeRects(in: item.range) {
|
||||
for rect in itemRects {
|
||||
var rect = rect
|
||||
rect = rect.insetBy(dx: 0.0, dy: -1.0)
|
||||
selectionRects.append(rect.offsetBy(dx: item.item.frame.minX, dy: item.item.frame.minY))
|
||||
}
|
||||
if let itemStart = itemStart, i == 0 {
|
||||
start = InstantPageTextRangeRectEdge(x: itemStart.x + item.item.frame.minX, y: itemStart.y + item.item.frame.minY, height: itemStart.height)
|
||||
}
|
||||
if let itemEnd = itemEnd, i == selection.items.count - 1 {
|
||||
end = InstantPageTextRangeRectEdge(x: itemEnd.x + item.item.frame.minX, y: itemEnd.y + item.item.frame.minY, height: itemEnd.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let start = start, let end = end {
|
||||
rects = (rects: selectionRects, start: start, end: end)
|
||||
}
|
||||
}
|
||||
|
||||
self.currentRects = rects?.rects
|
||||
|
||||
if let (rects, startEdge, endEdge) = rects, !rects.isEmpty {
|
||||
let highlightOverlay: LinkHighlightingNode
|
||||
if let current = self.highlightOverlay {
|
||||
highlightOverlay = current
|
||||
} else {
|
||||
highlightOverlay = LinkHighlightingNode(color: self.theme.selection)
|
||||
highlightOverlay.isUserInteractionEnabled = false
|
||||
highlightOverlay.innerRadius = 0.0
|
||||
highlightOverlay.outerRadius = 0.0
|
||||
highlightOverlay.inset = 1.0
|
||||
self.highlightOverlay = highlightOverlay
|
||||
self.highlightAreaNode.addSubnode(highlightOverlay)
|
||||
}
|
||||
highlightOverlay.frame = self.bounds
|
||||
highlightOverlay.updateRects(rects)
|
||||
if let image = self.leftKnob.image {
|
||||
self.leftKnob.frame = CGRect(origin: CGPoint(x: floor(startEdge.x - image.size.width / 2.0), y: startEdge.y + 1.0 - 12.0), size: CGSize(width: image.size.width, height: self.theme.knobDiameter + startEdge.height + 2.0))
|
||||
self.rightKnob.frame = CGRect(origin: CGPoint(x: floor(endEdge.x + 1.0 - image.size.width / 2.0), y: endEdge.y + endEdge.height + 3.0 - (endEdge.height + 2.0)), size: CGSize(width: image.size.width, height: self.theme.knobDiameter + endEdge.height + 2.0))
|
||||
}
|
||||
if self.leftKnob.alpha.isZero {
|
||||
highlightOverlay.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue)
|
||||
self.leftKnob.alpha = 1.0
|
||||
self.leftKnob.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.14, delay: 0.19)
|
||||
self.rightKnob.alpha = 1.0
|
||||
self.rightKnob.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.14, delay: 0.19)
|
||||
self.leftKnob.layer.animateSpring(from: 0.5 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.2, delay: 0.25, initialVelocity: 0.0, damping: 80.0)
|
||||
self.rightKnob.layer.animateSpring(from: 0.5 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.2, delay: 0.25, initialVelocity: 0.0, damping: 80.0)
|
||||
|
||||
if animateIn {
|
||||
var result = CGRect()
|
||||
for rect in rects {
|
||||
if result.isEmpty {
|
||||
result = rect
|
||||
} else {
|
||||
result = result.union(rect)
|
||||
}
|
||||
}
|
||||
highlightOverlay.layer.animateScale(from: 2.0, to: 1.0, duration: 0.26)
|
||||
let fromResult = CGRect(origin: CGPoint(x: result.minX - result.width / 2.0, y: result.minY - result.height / 2.0), size: CGSize(width: result.width * 2.0, height: result.height * 2.0))
|
||||
highlightOverlay.layer.animatePosition(from: CGPoint(x: (-fromResult.midX + highlightOverlay.bounds.midX) / 1.0, y: (-fromResult.midY + highlightOverlay.bounds.midY) / 1.0), to: CGPoint(), duration: 0.26, additive: true)
|
||||
}
|
||||
}
|
||||
} else if let highlightOverlay = self.highlightOverlay {
|
||||
self.highlightOverlay = nil
|
||||
highlightOverlay.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak highlightOverlay] _ in
|
||||
highlightOverlay?.removeFromSupernode()
|
||||
})
|
||||
self.leftKnob.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18)
|
||||
self.leftKnob.alpha = 0.0
|
||||
self.leftKnob.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18)
|
||||
self.rightKnob.alpha = 0.0
|
||||
self.rightKnob.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18)
|
||||
}
|
||||
}
|
||||
|
||||
private func knobAtPoint(_ point: CGPoint) -> (Knob, CGPoint)? {
|
||||
if !self.leftKnob.alpha.isZero, self.leftKnob.frame.insetBy(dx: -4.0, dy: -8.0).contains(point) {
|
||||
return (.left, self.leftKnob.frame.offsetBy(dx: 0.0, dy: self.leftKnob.frame.width / 2.0).center)
|
||||
}
|
||||
if !self.rightKnob.alpha.isZero, self.rightKnob.frame.insetBy(dx: -4.0, dy: -8.0).contains(point) {
|
||||
return (.right, self.rightKnob.frame.offsetBy(dx: 0.0, dy: -self.rightKnob.frame.width / 2.0).center)
|
||||
}
|
||||
if !self.leftKnob.alpha.isZero, self.leftKnob.frame.insetBy(dx: -14.0, dy: -14.0).contains(point) {
|
||||
return (.left, self.leftKnob.frame.offsetBy(dx: 0.0, dy: self.leftKnob.frame.width / 2.0).center)
|
||||
}
|
||||
if !self.rightKnob.alpha.isZero, self.rightKnob.frame.insetBy(dx: -14.0, dy: -14.0).contains(point) {
|
||||
return (.right, self.rightKnob.frame.offsetBy(dx: 0.0, dy: -self.rightKnob.frame.width / 2.0).center)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func dismissSelection() {
|
||||
self.currentSelection = nil
|
||||
self.updateSelection(selection: nil, animateIn: false)
|
||||
}
|
||||
|
||||
private func displayMenu() {
|
||||
// guard let currentRects = self.currentRects, !currentRects.isEmpty, let currentRange = self.currentRange, let cachedLayout = self.textNode.cachedLayout, let attributedString = cachedLayout.attributedString else {
|
||||
// return
|
||||
// }
|
||||
// let range = NSRange(location: min(currentRange.0, currentRange.1), length: max(currentRange.0, currentRange.1) - min(currentRange.0, currentRange.1))
|
||||
// var completeRect = currentRects[0]
|
||||
// for i in 0 ..< currentRects.count {
|
||||
// completeRect = completeRect.union(currentRects[i])
|
||||
// }
|
||||
// completeRect = completeRect.insetBy(dx: 0.0, dy: -12.0)
|
||||
//
|
||||
// let text = (attributedString.string as NSString).substring(with: range)
|
||||
|
||||
guard let currentRects = self.currentRects, !currentRects.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
var completeRect = currentRects[0]
|
||||
for i in 0 ..< currentRects.count {
|
||||
completeRect = completeRect.union(currentRects[i])
|
||||
}
|
||||
completeRect = completeRect.insetBy(dx: 0.0, dy: -12.0)
|
||||
|
||||
let text = "Text"
|
||||
|
||||
var actions: [ContextMenuAction] = []
|
||||
actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.strings.Conversation_ContextMenuCopy), action: { [weak self] in
|
||||
self?.performAction(text, .copy)
|
||||
self?.dismissSelection()
|
||||
}))
|
||||
actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuLookUp, accessibilityLabel: self.strings.Conversation_ContextMenuLookUp), action: { [weak self] in
|
||||
self?.performAction(text, .lookup)
|
||||
self?.dismissSelection()
|
||||
}))
|
||||
actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuShare, accessibilityLabel: self.strings.Conversation_ContextMenuShare), action: { [weak self] in
|
||||
self?.performAction(text, .share)
|
||||
self?.dismissSelection()
|
||||
}))
|
||||
self.present(ContextMenuController(actions: actions, catchTapsOutside: false, hasHapticFeedback: false), ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in
|
||||
guard let strongSelf = self, let rootNode = strongSelf.rootNode else {
|
||||
return nil
|
||||
}
|
||||
return (strongSelf, completeRect, rootNode, rootNode.bounds)
|
||||
}, bounce: false))
|
||||
}
|
||||
|
||||
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
if self.knobAtPoint(point) != nil {
|
||||
return self.view
|
||||
}
|
||||
if self.bounds.contains(point) {
|
||||
return self.view
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user