diff --git a/submodules/AccountContext/Sources/ChatController.swift b/submodules/AccountContext/Sources/ChatController.swift index 26832ba114..a002ecd6b6 100644 --- a/submodules/AccountContext/Sources/ChatController.swift +++ b/submodules/AccountContext/Sources/ChatController.swift @@ -321,6 +321,7 @@ public enum ChatTextInputStateTextAttributeType: Codable, Equatable { case strikethrough case underline case spoiler + case quote public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: StringCodingKey.self) @@ -348,6 +349,8 @@ public enum ChatTextInputStateTextAttributeType: Codable, Equatable { self = .underline case 8: self = .spoiler + case 9: + self = .quote default: assertionFailure() self = .bold @@ -379,6 +382,8 @@ public enum ChatTextInputStateTextAttributeType: Codable, Equatable { try container.encode(7 as Int32, forKey: "t") case .spoiler: try container.encode(8 as Int32, forKey: "t") + case .quote: + try container.encode(0 as Int32, forKey: "t") } } } @@ -452,6 +457,9 @@ public struct ChatTextInputStateText: Codable, Equatable { parsedAttributes.append(ChatTextInputStateTextAttribute(type: .underline, range: range.location ..< (range.location + range.length))) } else if key == ChatTextInputAttributes.spoiler { parsedAttributes.append(ChatTextInputStateTextAttribute(type: .spoiler, range: range.location ..< (range.location + range.length))) + } else if key == ChatTextInputAttributes.quote, let value = value as? ChatTextInputTextQuoteAttribute { + let _ = value + parsedAttributes.append(ChatTextInputStateTextAttribute(type: .quote, range: range.location ..< (range.location + range.length))) } } }) @@ -496,6 +504,8 @@ public struct ChatTextInputStateText: Codable, Equatable { result.addAttribute(ChatTextInputAttributes.underline, value: true as NSNumber, range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count)) case .spoiler: result.addAttribute(ChatTextInputAttributes.spoiler, value: true as NSNumber, range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count)) + case .quote: + result.addAttribute(ChatTextInputAttributes.quote, value: ChatTextInputTextQuoteAttribute(), range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count)) } } return result diff --git a/submodules/AsyncDisplayKit/Source/ASTextKitComponents.mm b/submodules/AsyncDisplayKit/Source/ASTextKitComponents.mm index bba12025a8..be98e25708 100644 --- a/submodules/AsyncDisplayKit/Source/ASTextKitComponents.mm +++ b/submodules/AsyncDisplayKit/Source/ASTextKitComponents.mm @@ -16,6 +16,13 @@ @implementation ASCustomTextContainer +- (instancetype)initWithSize:(CGSize)size textStorage:(NSTextStorage *)textStorage { + self = [super initWithSize:size]; + if (self != nil) { + } + return self; +} + - (CGRect)lineFragmentRectForProposedRect:(CGRect)proposedRect atIndex:(NSUInteger)characterIndex writingDirection:(NSWritingDirection)baseWritingDirection remainingRect:(nullable CGRect *)remainingRect { CGRect result = [super lineFragmentRectForProposedRect:proposedRect atIndex:characterIndex writingDirection:baseWritingDirection remainingRect:remainingRect]; @@ -139,8 +146,7 @@ components.layoutManager = layoutManager; [components.textStorage addLayoutManager:components.layoutManager]; - components.textContainer = [[ASCustomTextContainer alloc] initWithSize:textContainerSize]; - //components.textContainer.exclusionPaths = @[[UIBezierPath bezierPathWithRect:CGRectMake(textContainerSize.width - 60.0, 0.0, 60.0, 40.0)]]; + components.textContainer = [[ASCustomTextContainer alloc] initWithSize:textContainerSize textStorage:textStorage]; components.textContainer.lineFragmentPadding = 0.0; // We want the text laid out up to the very edges of the text-view. [components.layoutManager addTextContainer:components.textContainer]; diff --git a/submodules/AsyncDisplayKit/Source/ASTextKitContext.h b/submodules/AsyncDisplayKit/Source/ASTextKitContext.h index 5decde42f7..13134b763b 100644 --- a/submodules/AsyncDisplayKit/Source/ASTextKitContext.h +++ b/submodules/AsyncDisplayKit/Source/ASTextKitContext.h @@ -52,6 +52,8 @@ AS_SUBCLASSING_RESTRICTED @interface ASCustomTextContainer : NSTextContainer +- (instancetype)initWithSize:(CGSize)size textStorage:(NSTextStorage *)textStorage; + @end #endif diff --git a/submodules/AsyncDisplayKit/Source/ASTextKitContext.mm b/submodules/AsyncDisplayKit/Source/ASTextKitContext.mm index 8e9c276fef..aa9dada4fa 100644 --- a/submodules/AsyncDisplayKit/Source/ASTextKitContext.mm +++ b/submodules/AsyncDisplayKit/Source/ASTextKitContext.mm @@ -58,7 +58,7 @@ [_textStorage setAttributedString:attributedString]; } - _textContainer = [[ASCustomTextContainer alloc] initWithSize:constrainedSize]; + _textContainer = [[ASCustomTextContainer alloc] initWithSize:constrainedSize textStorage:nil]; // We want the text laid out up to the very edges of the container. _textContainer.lineFragmentPadding = 0; _textContainer.lineBreakMode = lineBreakMode; diff --git a/submodules/ChatPresentationInterfaceState/Sources/ChatTextFormat.swift b/submodules/ChatPresentationInterfaceState/Sources/ChatTextFormat.swift index b4fd3e9b6d..64f6df38e7 100644 --- a/submodules/ChatPresentationInterfaceState/Sources/ChatTextFormat.swift +++ b/submodules/ChatPresentationInterfaceState/Sources/ChatTextFormat.swift @@ -106,3 +106,29 @@ public func chatTextInputAddMentionAttribute(_ state: ChatTextInputState, peer: return state } } + +public func chatTextInputAddQuoteAttribute(_ state: ChatTextInputState, selectionRange: Range) -> ChatTextInputState { + if selectionRange.isEmpty { + return state + } + let nsRange = NSRange(location: selectionRange.lowerBound, length: selectionRange.count) + var quoteRange = nsRange + var attributesToRemove: [(NSAttributedString.Key, NSRange)] = [] + state.inputText.enumerateAttributes(in: nsRange, options: .longestEffectiveRangeNotRequired) { attributes, range, stop in + for (key, _) in attributes { + if key == ChatTextInputAttributes.quote { + attributesToRemove.append((key, range)) + quoteRange = quoteRange.union(range) + } else { + attributesToRemove.append((key, nsRange)) + } + } + } + + let result = NSMutableAttributedString(attributedString: state.inputText) + for (attribute, range) in attributesToRemove { + result.removeAttribute(attribute, range: range) + } + result.addAttribute(ChatTextInputAttributes.quote, value: ChatTextInputTextQuoteAttribute(), range: nsRange) + return ChatTextInputState(inputText: result, selectionRange: selectionRange) +} diff --git a/submodules/Display/Source/InteractiveTransitionGestureRecognizer.swift b/submodules/Display/Source/InteractiveTransitionGestureRecognizer.swift index 56120b515e..010434eded 100644 --- a/submodules/Display/Source/InteractiveTransitionGestureRecognizer.swift +++ b/submodules/Display/Source/InteractiveTransitionGestureRecognizer.swift @@ -61,8 +61,11 @@ public enum InteractiveTransitionGestureRecognizerEdgeWidth { } public class InteractiveTransitionGestureRecognizer: UIPanGestureRecognizer { - private let edgeWidth: InteractiveTransitionGestureRecognizerEdgeWidth + private let staticEdgeWidth: InteractiveTransitionGestureRecognizerEdgeWidth private let allowedDirections: (CGPoint) -> InteractiveTransitionGestureRecognizerDirections + public var dynamicEdgeWidth: ((CGPoint) -> InteractiveTransitionGestureRecognizerEdgeWidth)? + + private var currentEdgeWidth: InteractiveTransitionGestureRecognizerEdgeWidth private var validatedGesture = false private var firstLocation: CGPoint = CGPoint() @@ -70,7 +73,8 @@ public class InteractiveTransitionGestureRecognizer: UIPanGestureRecognizer { public init(target: Any?, action: Selector?, allowedDirections: @escaping (CGPoint) -> InteractiveTransitionGestureRecognizerDirections, edgeWidth: InteractiveTransitionGestureRecognizerEdgeWidth = .constant(16.0)) { self.allowedDirections = allowedDirections - self.edgeWidth = edgeWidth + self.staticEdgeWidth = edgeWidth + self.currentEdgeWidth = edgeWidth super.init(target: target, action: action) @@ -99,6 +103,10 @@ public class InteractiveTransitionGestureRecognizer: UIPanGestureRecognizer { return } + if let dynamicEdgeWidth = self.dynamicEdgeWidth { + self.currentEdgeWidth = dynamicEdgeWidth(point) + } + super.touchesBegan(touches, with: event) self.firstLocation = point @@ -151,7 +159,7 @@ public class InteractiveTransitionGestureRecognizer: UIPanGestureRecognizer { } } else { let edgeWidth: CGFloat - switch self.edgeWidth { + switch self.currentEdgeWidth { case let .constant(value): edgeWidth = value case let .widthMultiplier(factor, minValue, maxValue): diff --git a/submodules/Display/Source/Navigation/NavigationContainer.swift b/submodules/Display/Source/Navigation/NavigationContainer.swift index 683547b170..2a9614ff24 100644 --- a/submodules/Display/Source/Navigation/NavigationContainer.swift +++ b/submodules/Display/Source/Navigation/NavigationContainer.swift @@ -142,6 +142,12 @@ public final class NavigationContainer: ASDisplayNode, UIGestureRecognizerDelega } return .right }) + panRecognizer.dynamicEdgeWidth = { [weak self] _ in + guard let self, let controller = self.controllers.last, let value = controller.interactiveNavivationGestureEdgeWidth else { + return .constant(16.0) + } + return value + } if #available(iOS 13.4, *) { panRecognizer.allowedScrollTypesMask = .continuous } diff --git a/submodules/Display/Source/TextNode.swift b/submodules/Display/Source/TextNode.swift index 32517738df..08987d2556 100644 --- a/submodules/Display/Source/TextNode.swift +++ b/submodules/Display/Source/TextNode.swift @@ -2,9 +2,14 @@ import Foundation import UIKit import AsyncDisplayKit import CoreText +import AppBundle private let defaultFont = UIFont.systemFont(ofSize: 15.0) +private let quoteIcon: UIImage = { + return UIImage(bundleImageName: "Chat/Message/ReplyQuoteIcon")!.precomposed() +}() + private final class TextNodeStrikethrough { let range: NSRange let frame: CGRect @@ -62,10 +67,48 @@ public struct TextRangeRectEdge: Equatable { } } +public final class TextNodeBlockQuoteData: NSObject { + public let id: Int + public let title: NSAttributedString? + public let color: UIColor + + public init(id: Int, title: NSAttributedString?, color: UIColor) { + self.id = id + self.title = title + self.color = color + + super.init() + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? TextNodeBlockQuoteData else { + return false + } + + if self.id != other.id { + return false + } + if let lhsTitle = self.title, let rhsTitle = other.title { + if !lhsTitle.isEqual(to: rhsTitle) { + return false + } + } else if (self.title == nil) != (other.title == nil) { + return false + } + if !self.color.isEqual(other.color) { + return false + } + + return true + } +} + private final class TextNodeLine { let line: CTLine - let frame: CGRect - let range: NSRange + var frame: CGRect + let ascent: CGFloat + let descent: CGFloat + let range: NSRange? let isRTL: Bool let strikethroughs: [TextNodeStrikethrough] let spoilers: [TextNodeSpoiler] @@ -74,9 +117,11 @@ private final class TextNodeLine { let attachments: [TextNodeAttachment] let additionalTrailingLine: (CTLine, Double)? - init(line: CTLine, frame: CGRect, range: NSRange, isRTL: Bool, strikethroughs: [TextNodeStrikethrough], spoilers: [TextNodeSpoiler], spoilerWords: [TextNodeSpoiler], embeddedItems: [TextNodeEmbeddedItem], attachments: [TextNodeAttachment], additionalTrailingLine: (CTLine, Double)?) { + init(line: CTLine, frame: CGRect, ascent: CGFloat, descent: CGFloat, range: NSRange?, isRTL: Bool, strikethroughs: [TextNodeStrikethrough], spoilers: [TextNodeSpoiler], spoilerWords: [TextNodeSpoiler], embeddedItems: [TextNodeEmbeddedItem], attachments: [TextNodeAttachment], additionalTrailingLine: (CTLine, Double)?) { self.line = line self.frame = frame + self.ascent = ascent + self.descent = descent self.range = range self.isRTL = isRTL self.strikethroughs = strikethroughs @@ -90,9 +135,11 @@ private final class TextNodeLine { private final class TextNodeBlockQuote { let frame: CGRect + let tintColor: UIColor - init(frame: CGRect) { + init(frame: CGRect, tintColor: UIColor) { self.frame = frame + self.tintColor = tintColor } } @@ -402,7 +449,14 @@ public final class TextNodeLayout: NSObject { public var trailingLineWidth: CGFloat { if let lastLine = self.lines.last { - return lastLine.frame.maxX + var width = lastLine.frame.maxX + + for blockQuote in self.blockQuotes { + if lastLine.frame.intersects(blockQuote.frame) { + width = max(width, blockQuote.frame.maxX) + } + } + return width } else { return 0.0 } @@ -424,7 +478,7 @@ public final class TextNodeLayout: NSObject { var closestLine: (Int, CGRect, CGFloat)? for line in self.lines { lineIndex += 1 - var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + self.firstLineOffset), size: line.frame.size) + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + line.descent), size: line.frame.size) switch self.resolvedAlignment { case .center: lineFrame.origin.x = floor((self.size.width - lineFrame.size.width) / 2.0) @@ -492,7 +546,7 @@ public final class TextNodeLayout: NSObject { var lineIndex = -1 for line in self.lines { lineIndex += 1 - var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + self.firstLineOffset), size: line.frame.size) + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + line.descent), size: line.frame.size) switch self.resolvedAlignment { case .center: lineFrame.origin.x = floor((self.size.width - lineFrame.size.width) / 2.0) @@ -559,7 +613,7 @@ public final class TextNodeLayout: NSObject { } } if index >= 0 && index < attributedString.length { - if index < line.range.location + line.range.length { + if let range = line.range, index < range.location + range.length { return (index, attributedString.attributes(at: index, effectiveRange: nil)) } } @@ -569,7 +623,7 @@ public final class TextNodeLayout: NSObject { lineIndex = -1 for line in self.lines { lineIndex += 1 - var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + self.firstLineOffset), size: line.frame.size) + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + line.descent), size: line.frame.size) switch self.resolvedAlignment { case .center: lineFrame.origin.x = floor((self.size.width - lineFrame.size.width) / 2.0) @@ -636,7 +690,7 @@ public final class TextNodeLayout: NSObject { } } if index >= 0 && index < attributedString.length { - if index < line.range.location + line.range.length { + if let range = line.range, index < range.location + range.length { return (index, attributedString.attributes(at: index, effectiveRange: nil)) } } @@ -667,14 +721,17 @@ public final class TextNodeLayout: NSObject { var rects: [CGRect] = [] let range = NSRange(stringRange, in: searchText) for line in self.lines { - let lineRange = NSIntersectionRange(range, line.range) + guard let rangeValue = line.range else { + continue + } + let lineRange = NSIntersectionRange(range, rangeValue) if lineRange.length != 0 { var leftOffset: CGFloat = 0.0 - if lineRange.location != line.range.location { + if lineRange.location != rangeValue.location { leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil)) } var rightOffset: CGFloat = line.frame.width - if lineRange.location + lineRange.length != line.range.length { + if lineRange.location + lineRange.length != rangeValue.length { var secondaryOffset: CGFloat = 0.0 let rawOffset = CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, &secondaryOffset) rightOffset = ceil(rawOffset) @@ -682,7 +739,7 @@ public final class TextNodeLayout: NSObject { rightOffset = ceil(secondaryOffset) } } - var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + self.firstLineOffset), size: line.frame.size) + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + line.descent), size: line.frame.size) lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout) let width = abs(rightOffset - leftOffset) @@ -716,14 +773,17 @@ public final class TextNodeLayout: NSObject { if let value = value, range.length != 0 { var coveringRect = CGRect() for line in self.lines { - let lineRange = NSIntersectionRange(range, line.range) + guard let rangeValue = line.range else { + continue + } + let lineRange = NSIntersectionRange(range, rangeValue) if lineRange.length != 0 { var leftOffset: CGFloat = 0.0 - if lineRange.location != line.range.location { + if lineRange.location != rangeValue.location { leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil)) } var rightOffset: CGFloat = line.frame.width - if lineRange.location + lineRange.length != line.range.length { + if lineRange.location + lineRange.length != rangeValue.length { var secondaryOffset: CGFloat = 0.0 let rawOffset = CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, &secondaryOffset) rightOffset = ceil(rawOffset) @@ -732,7 +792,7 @@ public final class TextNodeLayout: NSObject { } } - var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + self.firstLineOffset), size: line.frame.size) + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + line.descent), size: line.frame.size) switch self.resolvedAlignment { case .center: lineFrame.origin.x = floor((self.size.width - lineFrame.size.width) / 2.0) @@ -765,14 +825,17 @@ public final class TextNodeLayout: NSObject { if range.length != 0 { var rects: [(CGRect, CGRect)] = [] for line in self.lines { - let lineRange = NSIntersectionRange(range, line.range) + guard let rangeValue = line.range else { + continue + } + let lineRange = NSIntersectionRange(range, rangeValue) if lineRange.length != 0 { var leftOffset: CGFloat = 0.0 - if lineRange.location != line.range.location || line.isRTL { + if lineRange.location != rangeValue.location || line.isRTL { leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil)) } var rightOffset: CGFloat = line.frame.width - if lineRange.location + lineRange.length != line.range.length || line.isRTL { + if lineRange.location + lineRange.length != rangeValue.length || line.isRTL { var secondaryOffset: CGFloat = 0.0 let rawOffset = CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, &secondaryOffset) rightOffset = ceil(rawOffset) @@ -780,7 +843,7 @@ public final class TextNodeLayout: NSObject { rightOffset = ceil(secondaryOffset) } } - var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + self.firstLineOffset), size: line.frame.size) + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + line.descent), size: line.frame.size) lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout) @@ -806,14 +869,17 @@ public final class TextNodeLayout: NSObject { var startEdge: TextRangeRectEdge? var endEdge: TextRangeRectEdge? for line in self.lines { - let lineRange = NSIntersectionRange(range, line.range) + guard let rangeValue = line.range else { + continue + } + let lineRange = NSIntersectionRange(range, rangeValue) if lineRange.length != 0 { var leftOffset: CGFloat = 0.0 - if lineRange.location != line.range.location || line.isRTL { + if lineRange.location != rangeValue.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 { + if lineRange.location + lineRange.length != rangeValue.upperBound || line.isRTL { var secondaryOffset: CGFloat = 0.0 let rawOffset = CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, &secondaryOffset) rightOffset = ceil(rawOffset) @@ -821,19 +887,19 @@ public final class TextNodeLayout: NSObject { rightOffset = ceil(secondaryOffset) } } - var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + self.firstLineOffset), size: line.frame.size) + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + line.descent), size: line.frame.size) lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: self.size), cutout: self.cutout) let width = max(0.0, abs(rightOffset - leftOffset)) - if line.range.contains(range.lowerBound) { + if rangeValue.contains(range.lowerBound) { let offsetX = floor(CTLineGetOffsetForStringIndex(line.line, range.lowerBound, nil)) startEdge = TextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height) } - if line.range.contains(range.upperBound - 1) { + if rangeValue.contains(range.upperBound - 1) { let offsetX: CGFloat - if line.range.upperBound == range.upperBound { + if rangeValue.upperBound == range.upperBound { offsetX = lineFrame.maxX } else { var secondaryOffset: CGFloat = 0.0 @@ -1022,386 +1088,788 @@ open class TextNode: ASDisplayNode { } } - static func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textShadowBlur: CGFloat?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool, displayEmbeddedItemsUnderSpoilers: Bool, customTruncationToken: NSAttributedString?) -> TextNodeLayout { - if let attributedString = attributedString { - let stringLength = attributedString.length - - let font: CTFont - let resolvedAlignment: NSTextAlignment - - if stringLength != 0 { - if let stringFont = attributedString.attribute(NSAttributedString.Key.font, at: 0, effectiveRange: nil) { - font = stringFont as! CTFont - } else { - font = defaultFont + private static func calculateLayoutV2( + attributedString: NSAttributedString, + minimumNumberOfLines: Int, + maximumNumberOfLines: Int, + truncationType: CTLineTruncationType, + backgroundColor: UIColor?, + constrainedSize: CGSize, + alignment: NSTextAlignment, + verticalAlignment: TextVerticalAlignment, + lineSpacingFactor: CGFloat, + cutout: TextNodeCutout?, + insets: UIEdgeInsets, + lineColor: UIColor?, + textShadowColor: UIColor?, + textShadowBlur: CGFloat?, + textStroke: (UIColor, CGFloat)?, + displaySpoilers: Bool, + displayEmbeddedItemsUnderSpoilers: Bool, + customTruncationToken: NSAttributedString? + ) -> TextNodeLayout { + let blockQuoteLeftInset: CGFloat = 7.0 + let blockQuoteRightInset: CGFloat = 0.0 + let blockQuoteIconInset: CGFloat = 12.0 + + struct StringSegment { + let title: NSAttributedString? + let substring: NSAttributedString + let firstCharacterOffset: Int + let isBlockQuote: Bool + let tintColor: UIColor? + } + var stringSegments: [StringSegment] = [] + + let rawWholeString = attributedString.string as NSString + let wholeStringLength = rawWholeString.length + + var segmentCharacterOffset = 0 + while true { + var found = false + attributedString.enumerateAttribute(NSAttributedString.Key("Attribute__Blockquote"), in: NSRange(location: segmentCharacterOffset, length: wholeStringLength - segmentCharacterOffset), using: { value, effectiveRange, _ in + found = true + if segmentCharacterOffset != effectiveRange.location { + stringSegments.append(StringSegment( + title: nil, + substring: attributedString.attributedSubstring(from: NSRange( + location: segmentCharacterOffset, + length: effectiveRange.location - segmentCharacterOffset + )), + firstCharacterOffset: segmentCharacterOffset, + isBlockQuote: false, + tintColor: nil + )) } - if alignment == .center { - resolvedAlignment = .center - } else { - if let paragraphStyle = attributedString.attribute(NSAttributedString.Key.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle { - resolvedAlignment = paragraphStyle.alignment - } else { - resolvedAlignment = alignment + + if let value = value as? TextNodeBlockQuoteData { + if effectiveRange.length != 0 { + stringSegments.append(StringSegment( + title: value.title, + substring: attributedString.attributedSubstring(from: effectiveRange), + firstCharacterOffset: effectiveRange.location, + isBlockQuote: true, + tintColor: value.color + )) } + } else { + stringSegments.append(StringSegment( + title: nil, + substring: attributedString.attributedSubstring(from: effectiveRange), + firstCharacterOffset: effectiveRange.location, + isBlockQuote: false, + tintColor: nil + )) + } + + segmentCharacterOffset = effectiveRange.location + effectiveRange.length + }) + if !found { + if segmentCharacterOffset != wholeStringLength { + stringSegments.append(StringSegment( + title: nil, + substring: attributedString.attributedSubstring(from: NSRange( + location: segmentCharacterOffset, + length: wholeStringLength - segmentCharacterOffset + )), + firstCharacterOffset: segmentCharacterOffset, + isBlockQuote: false, + tintColor: nil + )) + } + + break + } + } + + struct CalculatedSegment { + var titleLine: TextNodeLine? + var lines: [TextNodeLine] = [] + var tintColor: UIColor? + var isBlockQuote: Bool = false + var additionalWidth: CGFloat = 0.0 + } + + var calculatedSegments: [CalculatedSegment] = [] + + for segment in stringSegments { + var calculatedSegment = CalculatedSegment() + calculatedSegment.isBlockQuote = segment.isBlockQuote + calculatedSegment.tintColor = segment.tintColor + + let rawSubstring = segment.substring.string as NSString + let substringLength = rawSubstring.length + let typesetter = CTTypesetterCreateWithAttributedString(segment.substring as CFAttributedString) + + var currentLineStartIndex = 0 + + var constrainedSegmentWidth = constrainedSize.width + var additionalOffsetX: CGFloat = 0.0 + if segment.isBlockQuote { + constrainedSegmentWidth -= blockQuoteLeftInset + blockQuoteRightInset + additionalOffsetX += blockQuoteLeftInset + calculatedSegment.additionalWidth += blockQuoteLeftInset + blockQuoteRightInset + } + + var additionalSegmentRightInset = blockQuoteIconInset + + if let title = segment.title { + let rawTitleLine = CTLineCreateWithAttributedString(title) + if let titleLine = CTLineCreateTruncatedLine(rawTitleLine, constrainedSegmentWidth + additionalSegmentRightInset, .end, nil) { + var lineAscent: CGFloat = 0.0 + var lineDescent: CGFloat = 0.0 + let lineWidth = CTLineGetTypographicBounds(titleLine, &lineAscent, &lineDescent, nil) + calculatedSegment.titleLine = TextNodeLine( + line: titleLine, + frame: CGRect(origin: CGPoint(x: additionalOffsetX, y: 0.0), size: CGSize(width: lineWidth + additionalSegmentRightInset, height: lineAscent + lineDescent)), + ascent: lineAscent, + descent: lineDescent, + range: nil, + isRTL: false, + strikethroughs: [], + spoilers: [], + spoilerWords: [], + embeddedItems: [], + attachments: [], + additionalTrailingLine: nil + ) + additionalSegmentRightInset = 0.0 + } + } + + while true { + let lineCharacterCount = CTTypesetterSuggestLineBreak(typesetter, currentLineStartIndex, constrainedSegmentWidth + additionalSegmentRightInset) + + if lineCharacterCount != 0 { + let line = CTTypesetterCreateLine(typesetter, CFRange(location: currentLineStartIndex, length: lineCharacterCount)) + var lineAscent: CGFloat = 0.0 + var lineDescent: CGFloat = 0.0 + let lineWidth = CTLineGetTypographicBounds(line, &lineAscent, &lineDescent, nil) + calculatedSegment.lines.append(TextNodeLine( + line: line, + frame: CGRect(origin: CGPoint(x: additionalOffsetX, y: 0.0), size: CGSize(width: lineWidth + additionalSegmentRightInset, height: lineAscent + lineDescent)), + ascent: lineAscent, + descent: lineDescent, + range: NSRange(location: segment.firstCharacterOffset + currentLineStartIndex, length: lineCharacterCount), + isRTL: false, + strikethroughs: [], + spoilers: [], + spoilerWords: [], + embeddedItems: [], + attachments: [], + additionalTrailingLine: nil + )) + } + + additionalSegmentRightInset = 0.0 + + currentLineStartIndex += lineCharacterCount + + if currentLineStartIndex >= substringLength { + break + } + } + + calculatedSegments.append(calculatedSegment) + } + + var size = CGSize() + let isTruncated = false + + for segment in calculatedSegments { + if let titleLine = segment.titleLine { + size.width = max(size.width, titleLine.frame.origin.x + titleLine.frame.width + segment.additionalWidth) + } + for line in segment.lines { + size.width = max(size.width, line.frame.origin.x + line.frame.width + segment.additionalWidth) + } + } + + var lines: [TextNodeLine] = [] + var blockQuotes: [TextNodeBlockQuote] = [] + for i in 0 ..< calculatedSegments.count { + let segment = calculatedSegments[i] + if i != 0 { + if segment.isBlockQuote { + size.height += 6.0 } + } else { + if segment.isBlockQuote { + size.height += 7.0 + } + } + + let blockMinY = size.height - insets.bottom + var blockWidth: CGFloat = 0.0 + + if let titleLine = segment.titleLine { + titleLine.frame = CGRect(origin: CGPoint(x: titleLine.frame.origin.x + insets.left, y: -insets.bottom + size.height + titleLine.frame.size.height), size: titleLine.frame.size) + titleLine.frame.size.width += max(0.0, segment.additionalWidth - 2.0) + size.height += titleLine.frame.height + blockWidth = max(blockWidth, titleLine.frame.origin.x + titleLine.frame.width) + + lines.append(titleLine) + } + + for line in segment.lines { + line.frame = CGRect(origin: CGPoint(x: line.frame.origin.x + insets.left, y: -insets.bottom + size.height + line.frame.size.height), size: line.frame.size) + line.frame.size.width += max(0.0, segment.additionalWidth - 2.0) + //line.frame.size.width = max(blockWidth, line.frame.size.width) + size.height += line.frame.height + blockWidth = max(blockWidth, line.frame.origin.x + line.frame.width) + + lines.append(line) + } + + let blockMaxY = size.height - insets.bottom + + if i != calculatedSegments.count - 1 { + if segment.isBlockQuote { + size.height += 8.0 + } + } else { + if segment.isBlockQuote { + size.height += 6.0 + } + } + + if segment.isBlockQuote, let tintColor = segment.tintColor { + blockQuotes.append(TextNodeBlockQuote(frame: CGRect(origin: CGPoint(x: 0.0, y: blockMinY - 2.0), size: CGSize(width: blockWidth, height: blockMaxY - (blockMinY - 2.0) + 4.0)), tintColor: tintColor)) + } + } + + let rawTextSize = size + size.width += insets.left + insets.right + size.height += insets.top + insets.bottom + + return TextNodeLayout( + attributedString: attributedString, + maximumNumberOfLines: maximumNumberOfLines, + truncationType: truncationType, + constrainedSize: constrainedSize, + explicitAlignment: alignment, + resolvedAlignment: alignment, + verticalAlignment: verticalAlignment, + lineSpacing: lineSpacingFactor, + cutout: cutout, + insets: insets, + size: size, + rawTextSize: rawTextSize, + truncated: isTruncated, + firstLineOffset: lines.first?.descent ?? 0.0, + lines: lines, + blockQuotes: blockQuotes, + backgroundColor: backgroundColor, + lineColor: lineColor, + textShadowColor: textShadowColor, + textShadowBlur: textShadowBlur, + textStroke: textStroke, + displaySpoilers: displaySpoilers + ) + } + + static func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textShadowBlur: CGFloat?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool, displayEmbeddedItemsUnderSpoilers: Bool, customTruncationToken: NSAttributedString?) -> TextNodeLayout { + guard let attributedString else { + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: alignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers) + } + + if "".isEmpty, maximumNumberOfLines == 0 { + return calculateLayoutV2(attributedString: attributedString, minimumNumberOfLines: minimumNumberOfLines, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, backgroundColor: backgroundColor, constrainedSize: constrainedSize, alignment: alignment, verticalAlignment: verticalAlignment, lineSpacingFactor: lineSpacingFactor, cutout: cutout, insets: insets, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers, displayEmbeddedItemsUnderSpoilers: displayEmbeddedItemsUnderSpoilers, customTruncationToken: customTruncationToken) + } + + let stringLength = attributedString.length + + let font: CTFont + let resolvedAlignment: NSTextAlignment + + if stringLength != 0 { + if let stringFont = attributedString.attribute(NSAttributedString.Key.font, at: 0, effectiveRange: nil) { + font = stringFont as! CTFont } else { font = defaultFont - resolvedAlignment = alignment + } + if alignment == .center { + resolvedAlignment = .center + } else { + if let paragraphStyle = attributedString.attribute(NSAttributedString.Key.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle { + resolvedAlignment = paragraphStyle.alignment + } else { + resolvedAlignment = alignment + } + } + } else { + font = defaultFont + resolvedAlignment = alignment + } + + let fontAscent = CTFontGetAscent(font) + let fontDescent = CTFontGetDescent(font) + let fontLineHeight = floor(fontAscent + fontDescent) + let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor) + + var lines: [TextNodeLine] = [] + let blockQuotes: [TextNodeBlockQuote] = [] + + var maybeTypesetter: CTTypesetter? + maybeTypesetter = CTTypesetterCreateWithAttributedString(attributedString as CFAttributedString) + if maybeTypesetter == nil { + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers) + } + + let typesetter = maybeTypesetter! + + var lastLineCharacterIndex: CFIndex = 0 + var layoutSize = CGSize() + + var cutoutEnabled = false + var cutoutMinY: CGFloat = 0.0 + var cutoutMaxY: CGFloat = 0.0 + var cutoutWidth: CGFloat = 0.0 + var cutoutOffset: CGFloat = 0.0 + + var bottomCutoutEnabled = false + var bottomCutoutSize = CGSize() + + if let topLeft = cutout?.topLeft { + cutoutMinY = -fontLineSpacing + cutoutMaxY = topLeft.height + fontLineSpacing + cutoutWidth = topLeft.width + cutoutOffset = cutoutWidth + cutoutEnabled = true + } else if let topRight = cutout?.topRight { + cutoutMinY = -fontLineSpacing + cutoutMaxY = topRight.height + fontLineSpacing + cutoutWidth = topRight.width + cutoutEnabled = true + } + + if let bottomRight = cutout?.bottomRight { + bottomCutoutSize = bottomRight + bottomCutoutEnabled = true + } + + let firstLineOffset = floorToScreenPixels(fontDescent) + + var truncated = false + var first = true + while true { + var strikethroughs: [TextNodeStrikethrough] = [] + var spoilers: [TextNodeSpoiler] = [] + var spoilerWords: [TextNodeSpoiler] = [] + var embeddedItems: [TextNodeEmbeddedItem] = [] + var attachments: [TextNodeAttachment] = [] + + var lineConstrainedWidth = constrainedSize.width + var lineConstrainedWidthDelta: CGFloat = 0.0 + var lineOriginY = floorToScreenPixels(layoutSize.height + fontAscent) + if !first { + lineOriginY += fontLineSpacing + } + var lineCutoutOffset: CGFloat = 0.0 + var lineAdditionalWidth: CGFloat = 0.0 + + if cutoutEnabled { + if lineOriginY - fontLineHeight < cutoutMaxY && lineOriginY + fontLineHeight > cutoutMinY { + lineConstrainedWidth = max(1.0, lineConstrainedWidth - cutoutWidth) + lineConstrainedWidthDelta = -cutoutWidth + lineCutoutOffset = cutoutOffset + lineAdditionalWidth = cutoutWidth + } } - let fontAscent = CTFontGetAscent(font) - let fontDescent = CTFontGetDescent(font) - let fontLineHeight = floor(fontAscent + fontDescent) - let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor) + let lineCharacterCount = CTTypesetterSuggestLineBreak(typesetter, lastLineCharacterIndex, Double(lineConstrainedWidth)) - var lines: [TextNodeLine] = [] - var blockQuotes: [TextNodeBlockQuote] = [] - - var maybeTypesetter: CTTypesetter? - maybeTypesetter = CTTypesetterCreateWithAttributedString(attributedString as CFAttributedString) - if maybeTypesetter == nil { - return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers) + func addSpoiler(line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int) { + var secondaryLeftOffset: CGFloat = 0.0 + let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset) + var leftOffset = floor(rawLeftOffset) + if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { + leftOffset = floor(secondaryLeftOffset) + } + + var secondaryRightOffset: CGFloat = 0.0 + let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset) + var rightOffset = ceil(rawRightOffset) + if !rawRightOffset.isEqual(to: secondaryRightOffset) { + rightOffset = ceil(secondaryRightOffset) + } + + spoilers.append(TextNodeSpoiler(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset), height: ascent + descent))) } - let typesetter = maybeTypesetter! + func addSpoilerWord(line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) { + var secondaryLeftOffset: CGFloat = 0.0 + let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset) + var leftOffset = floor(rawLeftOffset) + if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { + leftOffset = floor(secondaryLeftOffset) + } + + var secondaryRightOffset: CGFloat = 0.0 + let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset) + var rightOffset = ceil(rawRightOffset) + if !rawRightOffset.isEqual(to: secondaryRightOffset) { + rightOffset = ceil(secondaryRightOffset) + } + + spoilerWords.append(TextNodeSpoiler(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent))) + } - var lastLineCharacterIndex: CFIndex = 0 - var layoutSize = CGSize() + func addEmbeddedItem(item: AnyHashable, line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) { + var secondaryLeftOffset: CGFloat = 0.0 + let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset) + var leftOffset = floor(rawLeftOffset) + if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { + leftOffset = floor(secondaryLeftOffset) + } + + var secondaryRightOffset: CGFloat = 0.0 + let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset) + var rightOffset = ceil(rawRightOffset) + if !rawRightOffset.isEqual(to: secondaryRightOffset) { + rightOffset = ceil(secondaryRightOffset) + } + + embeddedItems.append(TextNodeEmbeddedItem(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent), item: item)) + } - var cutoutEnabled = false - var cutoutMinY: CGFloat = 0.0 - var cutoutMaxY: CGFloat = 0.0 - var cutoutWidth: CGFloat = 0.0 - var cutoutOffset: CGFloat = 0.0 + func addAttachment(attachment: UIImage, line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) { + var secondaryLeftOffset: CGFloat = 0.0 + let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset) + var leftOffset = floor(rawLeftOffset) + if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { + leftOffset = floor(secondaryLeftOffset) + } + + var secondaryRightOffset: CGFloat = 0.0 + let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset) + var rightOffset = ceil(rawRightOffset) + if !rawRightOffset.isEqual(to: secondaryRightOffset) { + rightOffset = ceil(secondaryRightOffset) + } + + attachments.append(TextNodeAttachment(range: NSMakeRange(startIndex, endIndex - startIndex), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent), attachment: attachment)) + } - var bottomCutoutEnabled = false - var bottomCutoutSize = CGSize() + var isLastLine = false + if maximumNumberOfLines != 0 && lines.count == maximumNumberOfLines - 1 && lineCharacterCount > 0 { + isLastLine = true + } else if layoutSize.height + (fontLineSpacing + fontLineHeight) * 2.0 > constrainedSize.height { + isLastLine = true + } + if isLastLine { + if first { + first = false + } else { + layoutSize.height += fontLineSpacing + } + + var didClipLinebreak = false + var lineRange = CFRange(location: lastLineCharacterIndex, length: stringLength - lastLineCharacterIndex) + let nsString = (attributedString.string as NSString) + for i in lineRange.location ..< (lineRange.location + lineRange.length) { + if nsString.character(at: i) == 0x0a { + lineRange.length = max(0, i - lineRange.location) + didClipLinebreak = true + break + } + } + + var brokenLineRange = CFRange(location: lastLineCharacterIndex, length: lineCharacterCount) + if brokenLineRange.location + brokenLineRange.length > attributedString.length { + brokenLineRange.length = attributedString.length - brokenLineRange.location + } + if lineRange.length == 0 && !didClipLinebreak { + break + } + + let coreTextLine: CTLine + let originalLine = CTTypesetterCreateLineWithOffset(typesetter, lineRange, 0.0) + + var lineConstrainedSize = constrainedSize + lineConstrainedSize.width += lineConstrainedWidthDelta + if bottomCutoutEnabled { + lineConstrainedSize.width -= bottomCutoutSize.width + } + + let truncatedTokenString: NSAttributedString + if let customTruncationToken { + if lineRange.length == 0 && customTruncationToken.string.hasPrefix("\u{2026} ") { + truncatedTokenString = customTruncationToken.attributedSubstring(from: NSRange(location: 2, length: customTruncationToken.length - 2)) + } else { + truncatedTokenString = customTruncationToken + } + } else { + var truncationTokenAttributes: [NSAttributedString.Key : AnyObject] = [:] + truncationTokenAttributes[NSAttributedString.Key.font] = font + truncationTokenAttributes[NSAttributedString.Key(rawValue: kCTForegroundColorFromContextAttributeName as String)] = true as NSNumber + let tokenString = "\u{2026}" + + truncatedTokenString = NSAttributedString(string: tokenString, attributes: truncationTokenAttributes) + } + let truncationToken = CTLineCreateWithAttributedString(truncatedTokenString) + let truncationTokenWidth = CTLineGetTypographicBounds(truncationToken, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(truncationToken) + + var effectiveLineRange = brokenLineRange + var additionalTrailingLine: (CTLine, Double)? + + var measureFitWidth = CTLineGetTypographicBounds(originalLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(originalLine) + if customTruncationToken != nil && lineRange.location + lineRange.length < attributedString.length { + measureFitWidth += truncationTokenWidth + } + + if lineRange.length == 0 || measureFitWidth < Double(lineConstrainedSize.width) { + if didClipLinebreak { + if lineRange.length == 0 { + coreTextLine = CTLineCreateWithAttributedString(NSAttributedString()) + } else { + coreTextLine = originalLine + } + additionalTrailingLine = (truncationToken, truncationTokenWidth) - if let topLeft = cutout?.topLeft { - cutoutMinY = -fontLineSpacing - cutoutMaxY = topLeft.height + fontLineSpacing - cutoutWidth = topLeft.width - cutoutOffset = cutoutWidth - cutoutEnabled = true - } else if let topRight = cutout?.topRight { - cutoutMinY = -fontLineSpacing - cutoutMaxY = topRight.height + fontLineSpacing - cutoutWidth = topRight.width - cutoutEnabled = true - } - - if let bottomRight = cutout?.bottomRight { - bottomCutoutSize = bottomRight - bottomCutoutEnabled = true - } - - let firstLineOffset = floorToScreenPixels(fontDescent) - - var truncated = false - var first = true - while true { - var strikethroughs: [TextNodeStrikethrough] = [] - var spoilers: [TextNodeSpoiler] = [] - var spoilerWords: [TextNodeSpoiler] = [] - var embeddedItems: [TextNodeEmbeddedItem] = [] - var attachments: [TextNodeAttachment] = [] - - var lineConstrainedWidth = constrainedSize.width - var lineConstrainedWidthDelta: CGFloat = 0.0 - var lineOriginY = floorToScreenPixels(layoutSize.height + fontAscent) - if !first { - lineOriginY += fontLineSpacing + truncated = true + } else { + coreTextLine = originalLine + } + } else { + if customTruncationToken != nil { + let coreTextLine1 = CTLineCreateTruncatedLine(originalLine, max(1.0, Double(lineConstrainedSize.width)), truncationType, truncationToken) ?? truncationToken + let runs = (CTLineGetGlyphRuns(coreTextLine1) as [AnyObject]) as! [CTRun] + var hasTruncationToken = false + for run in runs { + let runRange = CTRunGetStringRange(run) + if runRange.location + runRange.length >= nsString.length { + hasTruncationToken = true + break + } + } + + if hasTruncationToken { + coreTextLine = coreTextLine1 + } else { + let coreTextLine2 = CTLineCreateTruncatedLine(originalLine, max(1.0, Double(lineConstrainedSize.width) - truncationTokenWidth), truncationType, truncationToken) ?? truncationToken + coreTextLine = coreTextLine2 + } + } else { + coreTextLine = CTLineCreateTruncatedLine(originalLine, max(1.0, Double(lineConstrainedSize.width)), truncationType, truncationToken) ?? truncationToken + } + let runs = (CTLineGetGlyphRuns(coreTextLine) as [AnyObject]) as! [CTRun] + for run in runs { + let runAttributes: NSDictionary = CTRunGetAttributes(run) + if let _ = runAttributes["CTForegroundColorFromContext"] { + brokenLineRange.length = CTRunGetStringRange(run).location - brokenLineRange.location + break + } + } + if customTruncationToken != nil { + assert(true) + } + effectiveLineRange = CFRange(location: effectiveLineRange.location, length: 0) + for run in runs { + let runRange = CTRunGetStringRange(run) + if runRange.location + runRange.length > brokenLineRange.location + brokenLineRange.length { + continue + } + effectiveLineRange.length = max(effectiveLineRange.length, (runRange.location + runRange.length) - effectiveLineRange.location) + } + + if brokenLineRange.location + brokenLineRange.length > attributedString.length { + brokenLineRange.length = attributedString.length - brokenLineRange.location + } + if effectiveLineRange.location + effectiveLineRange.length > attributedString.length { + effectiveLineRange.length = attributedString.length - effectiveLineRange.location + } + truncated = true } - var lineCutoutOffset: CGFloat = 0.0 - var lineAdditionalWidth: CGFloat = 0.0 - if cutoutEnabled { - if lineOriginY - fontLineHeight < cutoutMaxY && lineOriginY + fontLineHeight > cutoutMinY { - lineConstrainedWidth = max(1.0, lineConstrainedWidth - cutoutWidth) - lineConstrainedWidthDelta = -cutoutWidth - lineCutoutOffset = cutoutOffset - lineAdditionalWidth = cutoutWidth + var headIndent: CGFloat = 0.0 + if brokenLineRange.location >= 0 && brokenLineRange.length > 0 && brokenLineRange.location + brokenLineRange.length <= attributedString.length { + attributedString.enumerateAttributes(in: NSMakeRange(brokenLineRange.location, brokenLineRange.length), options: []) { attributes, range, _ in + if attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) + + var startIndex: Int? + var currentIndex: Int? + + let nsString = (attributedString.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 + addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, 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 + addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex, rightInset: truncated ? 12.0 : 0.0) + } + + addSpoiler(line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) + } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { + let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) + let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) + let x = lowerX < upperX ? lowerX : upperX + strikethroughs.append(TextNodeStrikethrough(range: range, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: fontLineHeight))) + } else if let paragraphStyle = attributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle { + headIndent = paragraphStyle.headIndent + } + + if let embeddedItem = (attributes[NSAttributedString.Key(rawValue: "TelegramEmbeddedItem")] as? AnyHashable ?? attributes[NSAttributedString.Key(rawValue: "Attribute__EmbeddedItem")] as? AnyHashable) { + if displayEmbeddedItemsUnderSpoilers || (attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] == nil && attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] == nil) { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) + + addEmbeddedItem(item: embeddedItem, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) + } + } + + if let attachment = attributes[NSAttributedString.Key.attachment] as? UIImage { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) + + addAttachment(attachment: attachment, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) + } } } - let lineCharacterCount = CTTypesetterSuggestLineBreak(typesetter, lastLineCharacterIndex, Double(lineConstrainedWidth)) + var lineAscent: CGFloat = 0.0 + var lineDescent: CGFloat = 0.0 + let lineWidth = min(lineConstrainedSize.width, ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, &lineAscent, &lineDescent, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine)))) + let lineFrame = CGRect(x: lineCutoutOffset + headIndent, y: lineOriginY, width: lineWidth, height: fontLineHeight) + layoutSize.height += fontLineHeight + fontLineSpacing - func addSpoiler(line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int) { - var secondaryLeftOffset: CGFloat = 0.0 - let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset) - var leftOffset = floor(rawLeftOffset) - if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { - leftOffset = floor(secondaryLeftOffset) - } - - var secondaryRightOffset: CGFloat = 0.0 - let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset) - var rightOffset = ceil(rawRightOffset) - if !rawRightOffset.isEqual(to: secondaryRightOffset) { - rightOffset = ceil(secondaryRightOffset) - } - - spoilers.append(TextNodeSpoiler(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset), height: ascent + descent))) + if let (_, additionalTrailingLineWidth) = additionalTrailingLine { + lineAdditionalWidth += additionalTrailingLineWidth } - func addSpoilerWord(line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) { - var secondaryLeftOffset: CGFloat = 0.0 - let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset) - var leftOffset = floor(rawLeftOffset) - if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { - leftOffset = floor(secondaryLeftOffset) - } - - var secondaryRightOffset: CGFloat = 0.0 - let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset) - var rightOffset = ceil(rawRightOffset) - if !rawRightOffset.isEqual(to: secondaryRightOffset) { - rightOffset = ceil(secondaryRightOffset) - } - - spoilerWords.append(TextNodeSpoiler(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent))) + layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth) + + if headIndent > 0.0 { + //blockQuotes.append(TextNodeBlockQuote(frame: lineFrame)) } - func addEmbeddedItem(item: AnyHashable, line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) { - var secondaryLeftOffset: CGFloat = 0.0 - let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset) - var leftOffset = floor(rawLeftOffset) - if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { - leftOffset = floor(secondaryLeftOffset) + var isRTL = false + let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray + if glyphRuns.count != 0 { + let run = glyphRuns[0] as! CTRun + if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) { + isRTL = true } - - var secondaryRightOffset: CGFloat = 0.0 - let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset) - var rightOffset = ceil(rawRightOffset) - if !rawRightOffset.isEqual(to: secondaryRightOffset) { - rightOffset = ceil(secondaryRightOffset) - } - - embeddedItems.append(TextNodeEmbeddedItem(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent), item: item)) } - func addAttachment(attachment: UIImage, line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) { - var secondaryLeftOffset: CGFloat = 0.0 - let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset) - var leftOffset = floor(rawLeftOffset) - if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { - leftOffset = floor(secondaryLeftOffset) - } - - var secondaryRightOffset: CGFloat = 0.0 - let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset) - var rightOffset = ceil(rawRightOffset) - if !rawRightOffset.isEqual(to: secondaryRightOffset) { - rightOffset = ceil(secondaryRightOffset) - } - - attachments.append(TextNodeAttachment(range: NSMakeRange(startIndex, endIndex - startIndex), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent), attachment: attachment)) - } - - var isLastLine = false - if maximumNumberOfLines != 0 && lines.count == maximumNumberOfLines - 1 && lineCharacterCount > 0 { - isLastLine = true - } else if layoutSize.height + (fontLineSpacing + fontLineHeight) * 2.0 > constrainedSize.height { - isLastLine = true - } - if isLastLine { + lines.append(TextNodeLine( + line: coreTextLine, + frame: lineFrame, + ascent: lineAscent, + descent: lineDescent, + range: NSMakeRange(effectiveLineRange.location, effectiveLineRange.length), + isRTL: isRTL, + strikethroughs: strikethroughs, + spoilers: spoilers, + spoilerWords: spoilerWords, + embeddedItems: embeddedItems, + attachments: attachments, + additionalTrailingLine: additionalTrailingLine + )) + break + } else { + if lineCharacterCount > 0 { if first { first = false } else { layoutSize.height += fontLineSpacing } - var didClipLinebreak = false - var lineRange = CFRange(location: lastLineCharacterIndex, length: stringLength - lastLineCharacterIndex) - let nsString = (attributedString.string as NSString) - for i in lineRange.location ..< (lineRange.location + lineRange.length) { - if nsString.character(at: i) == 0x0a { - lineRange.length = max(0, i - lineRange.location) - didClipLinebreak = true - break - } + var lineRange = CFRangeMake(lastLineCharacterIndex, lineCharacterCount) + if lineRange.location + lineRange.length > attributedString.length { + lineRange.length = attributedString.length - lineRange.location } - - var brokenLineRange = CFRange(location: lastLineCharacterIndex, length: lineCharacterCount) - if brokenLineRange.location + brokenLineRange.length > attributedString.length { - brokenLineRange.length = attributedString.length - brokenLineRange.location - } - if lineRange.length == 0 && !didClipLinebreak { + if lineRange.length < 0 { break } - - let coreTextLine: CTLine - let originalLine = CTTypesetterCreateLineWithOffset(typesetter, lineRange, 0.0) - - var lineConstrainedSize = constrainedSize - lineConstrainedSize.width += lineConstrainedWidthDelta - if bottomCutoutEnabled { - lineConstrainedSize.width -= bottomCutoutSize.width - } - - let truncatedTokenString: NSAttributedString - if let customTruncationToken { - if lineRange.length == 0 && customTruncationToken.string.hasPrefix("\u{2026} ") { - truncatedTokenString = customTruncationToken.attributedSubstring(from: NSRange(location: 2, length: customTruncationToken.length - 2)) - } else { - truncatedTokenString = customTruncationToken - } - } else { - var truncationTokenAttributes: [NSAttributedString.Key : AnyObject] = [:] - truncationTokenAttributes[NSAttributedString.Key.font] = font - truncationTokenAttributes[NSAttributedString.Key(rawValue: kCTForegroundColorFromContextAttributeName as String)] = true as NSNumber - let tokenString = "\u{2026}" - - truncatedTokenString = NSAttributedString(string: tokenString, attributes: truncationTokenAttributes) - } - let truncationToken = CTLineCreateWithAttributedString(truncatedTokenString) - let truncationTokenWidth = CTLineGetTypographicBounds(truncationToken, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(truncationToken) - - var effectiveLineRange = brokenLineRange - var additionalTrailingLine: (CTLine, Double)? - - var measureFitWidth = CTLineGetTypographicBounds(originalLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(originalLine) - if customTruncationToken != nil && lineRange.location + lineRange.length < attributedString.length { - measureFitWidth += truncationTokenWidth - } - - if lineRange.length == 0 || measureFitWidth < Double(lineConstrainedSize.width) { - if didClipLinebreak { - if lineRange.length == 0 { - coreTextLine = CTLineCreateWithAttributedString(NSAttributedString()) - } else { - coreTextLine = originalLine - } - additionalTrailingLine = (truncationToken, truncationTokenWidth) - - truncated = true - } else { - coreTextLine = originalLine - } - } else { - if customTruncationToken != nil { - let coreTextLine1 = CTLineCreateTruncatedLine(originalLine, max(1.0, Double(lineConstrainedSize.width)), truncationType, truncationToken) ?? truncationToken - let runs = (CTLineGetGlyphRuns(coreTextLine1) as [AnyObject]) as! [CTRun] - var hasTruncationToken = false - for run in runs { - let runRange = CTRunGetStringRange(run) - if runRange.location + runRange.length >= nsString.length { - hasTruncationToken = true - break - } - } - - if hasTruncationToken { - coreTextLine = coreTextLine1 - } else { - let coreTextLine2 = CTLineCreateTruncatedLine(originalLine, max(1.0, Double(lineConstrainedSize.width) - truncationTokenWidth), truncationType, truncationToken) ?? truncationToken - coreTextLine = coreTextLine2 - } - } else { - coreTextLine = CTLineCreateTruncatedLine(originalLine, max(1.0, Double(lineConstrainedSize.width)), truncationType, truncationToken) ?? truncationToken - } - let runs = (CTLineGetGlyphRuns(coreTextLine) as [AnyObject]) as! [CTRun] - for run in runs { - let runAttributes: NSDictionary = CTRunGetAttributes(run) - if let _ = runAttributes["CTForegroundColorFromContext"] { - brokenLineRange.length = CTRunGetStringRange(run).location - brokenLineRange.location - break - } - } - if customTruncationToken != nil { - assert(true) - } - effectiveLineRange = CFRange(location: effectiveLineRange.location, length: 0) - for run in runs { - let runRange = CTRunGetStringRange(run) - if runRange.location + runRange.length > brokenLineRange.location + brokenLineRange.length { - continue - } - effectiveLineRange.length = max(effectiveLineRange.length, (runRange.location + runRange.length) - effectiveLineRange.location) - } - - if brokenLineRange.location + brokenLineRange.length > attributedString.length { - brokenLineRange.length = attributedString.length - brokenLineRange.location - } - if effectiveLineRange.location + effectiveLineRange.length > attributedString.length { - effectiveLineRange.length = attributedString.length - effectiveLineRange.location - } - truncated = true - } + + let coreTextLine = CTTypesetterCreateLineWithOffset(typesetter, lineRange, 100.0) + lastLineCharacterIndex += lineCharacterCount var headIndent: CGFloat = 0.0 - if brokenLineRange.location >= 0 && brokenLineRange.length > 0 && brokenLineRange.location + brokenLineRange.length <= attributedString.length { - attributedString.enumerateAttributes(in: NSMakeRange(brokenLineRange.location, brokenLineRange.length), options: []) { attributes, range, _ in - if attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil { - var ascent: CGFloat = 0.0 - var descent: CGFloat = 0.0 - CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) - - var startIndex: Int? - var currentIndex: Int? - - let nsString = (attributedString.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 - addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex) - } - } else if startIndex == nil { - startIndex = range.location + attributedString.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in + if attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) + + var startIndex: Int? + var currentIndex: Int? + + let nsString = (attributedString.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 + addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex) } - currentIndex = range.location + range.length + } else if startIndex == nil { + startIndex = range.location } - - if let currentStartIndex = startIndex, let currentIndex = currentIndex { - startIndex = nil - let endIndex = currentIndex - addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex, rightInset: truncated ? 12.0 : 0.0) - } - - addSpoiler(line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) - } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { - let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) - let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) - let x = lowerX < upperX ? lowerX : upperX - strikethroughs.append(TextNodeStrikethrough(range: range, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: fontLineHeight))) - } else if let paragraphStyle = attributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle { - headIndent = paragraphStyle.headIndent + currentIndex = range.location + range.length } - if let embeddedItem = (attributes[NSAttributedString.Key(rawValue: "TelegramEmbeddedItem")] as? AnyHashable ?? attributes[NSAttributedString.Key(rawValue: "Attribute__EmbeddedItem")] as? AnyHashable) { - if displayEmbeddedItemsUnderSpoilers || (attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] == nil && attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] == nil) { - var ascent: CGFloat = 0.0 - var descent: CGFloat = 0.0 - CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) - - addEmbeddedItem(item: embeddedItem, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) - } + if let currentStartIndex = startIndex, let currentIndex = currentIndex { + startIndex = nil + let endIndex = currentIndex + addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex) } - if let attachment = attributes[NSAttributedString.Key.attachment] as? UIImage { + addSpoiler(line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) + } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { + let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) + let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) + let x = lowerX < upperX ? lowerX : upperX + strikethroughs.append(TextNodeStrikethrough(range: range, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: fontLineHeight))) + } else if let paragraphStyle = attributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle { + headIndent = paragraphStyle.headIndent + } + + if let embeddedItem = (attributes[NSAttributedString.Key(rawValue: "TelegramEmbeddedItem")] as? AnyHashable ?? attributes[NSAttributedString.Key(rawValue: "Attribute__EmbeddedItem")] as? AnyHashable) { + if displayEmbeddedItemsUnderSpoilers || (attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] == nil && attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] == nil) { var ascent: CGFloat = 0.0 var descent: CGFloat = 0.0 CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) - addAttachment(attachment: attachment, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) + addEmbeddedItem(item: embeddedItem, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) } } + + if let attachment = attributes[NSAttributedString.Key.attachment] as? UIImage { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) + + addAttachment(attachment: attachment, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) + } } - let lineWidth = min(lineConstrainedSize.width, ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine)))) + var lineAscent: CGFloat = 0.0 + var lineDescent: CGFloat = 0.0 + let lineWidth = ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, &lineAscent, &lineDescent, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine))) let lineFrame = CGRect(x: lineCutoutOffset + headIndent, y: lineOriginY, width: lineWidth, height: fontLineHeight) - layoutSize.height += fontLineHeight + fontLineSpacing - - if let (_, additionalTrailingLineWidth) = additionalTrailingLine { - lineAdditionalWidth += additionalTrailingLineWidth - } - - layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth) + layoutSize.height += fontLineHeight + layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth + headIndent) if headIndent > 0.0 { - blockQuotes.append(TextNodeBlockQuote(frame: lineFrame)) + //blockQuotes.append(TextNodeBlockQuote(frame: lineFrame)) } var isRTL = false @@ -1413,141 +1881,53 @@ open class TextNode: ASDisplayNode { } } - lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(effectiveLineRange.location, effectiveLineRange.length), isRTL: isRTL, strikethroughs: strikethroughs, spoilers: spoilers, spoilerWords: spoilerWords, embeddedItems: embeddedItems, attachments: attachments, additionalTrailingLine: additionalTrailingLine)) - break + lines.append(TextNodeLine( + line: coreTextLine, + frame: lineFrame, + ascent: lineAscent, + descent: lineDescent, + range: NSMakeRange(lineRange.location, lineRange.length), + isRTL: isRTL, + strikethroughs: strikethroughs, + spoilers: spoilers, + spoilerWords: spoilerWords, + embeddedItems: embeddedItems, + attachments: attachments, + additionalTrailingLine: nil + )) } else { - if lineCharacterCount > 0 { - if first { - first = false - } else { - layoutSize.height += fontLineSpacing - } - - var lineRange = CFRangeMake(lastLineCharacterIndex, lineCharacterCount) - if lineRange.location + lineRange.length > attributedString.length { - lineRange.length = attributedString.length - lineRange.location - } - if lineRange.length < 0 { - break - } - - let coreTextLine = CTTypesetterCreateLineWithOffset(typesetter, lineRange, 100.0) - lastLineCharacterIndex += lineCharacterCount - - var headIndent: CGFloat = 0.0 - attributedString.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in - if attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil { - var ascent: CGFloat = 0.0 - var descent: CGFloat = 0.0 - CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) - - var startIndex: Int? - var currentIndex: Int? - - let nsString = (attributedString.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 - addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, 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 - addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex) - } - - addSpoiler(line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) - } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { - let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) - let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) - let x = lowerX < upperX ? lowerX : upperX - strikethroughs.append(TextNodeStrikethrough(range: range, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: fontLineHeight))) - } else if let paragraphStyle = attributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle { - headIndent = paragraphStyle.headIndent - } - - if let embeddedItem = (attributes[NSAttributedString.Key(rawValue: "TelegramEmbeddedItem")] as? AnyHashable ?? attributes[NSAttributedString.Key(rawValue: "Attribute__EmbeddedItem")] as? AnyHashable) { - if displayEmbeddedItemsUnderSpoilers || (attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] == nil && attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] == nil) { - var ascent: CGFloat = 0.0 - var descent: CGFloat = 0.0 - CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) - - addEmbeddedItem(item: embeddedItem, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) - } - } - - if let attachment = attributes[NSAttributedString.Key.attachment] as? UIImage { - var ascent: CGFloat = 0.0 - var descent: CGFloat = 0.0 - CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) - - addAttachment(attachment: attachment, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) - } - } - - let lineWidth = ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine))) - let lineFrame = CGRect(x: lineCutoutOffset + headIndent, y: lineOriginY, width: lineWidth, height: fontLineHeight) - layoutSize.height += fontLineHeight - layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth) - - if headIndent > 0.0 { - blockQuotes.append(TextNodeBlockQuote(frame: lineFrame)) - } - - var isRTL = false - let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray - if glyphRuns.count != 0 { - let run = glyphRuns[0] as! CTRun - if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) { - isRTL = true - } - } - - lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length), isRTL: isRTL, strikethroughs: strikethroughs, spoilers: spoilers, spoilerWords: spoilerWords, embeddedItems: embeddedItems, attachments: attachments, additionalTrailingLine: nil)) - } else { - if !lines.isEmpty { - layoutSize.height += fontLineSpacing - } - break - } - } - } - - let rawLayoutSize = layoutSize - if !lines.isEmpty && bottomCutoutEnabled { - let proposedWidth = lines[lines.count - 1].frame.width + bottomCutoutSize.width - if proposedWidth > layoutSize.width { - if proposedWidth <= constrainedSize.width + .ulpOfOne { - layoutSize.width = proposedWidth - } else { - layoutSize.height += bottomCutoutSize.height - } - } - } - - if lines.count < minimumNumberOfLines { - var lineCount = lines.count - while lineCount < minimumNumberOfLines { - if lineCount != 0 { + if !lines.isEmpty { layoutSize.height += fontLineSpacing } - layoutSize.height += fontLineHeight - lineCount += 1 + break } } - - return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(width: ceil(layoutSize.width) + insets.left + insets.right, height: ceil(layoutSize.height) + insets.top + insets.bottom), rawTextSize: CGSize(width: ceil(rawLayoutSize.width) + insets.left + insets.right, height: ceil(rawLayoutSize.height) + insets.top + insets.bottom), truncated: truncated, firstLineOffset: firstLineOffset, lines: lines, blockQuotes: blockQuotes, backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers) - } else { - return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: alignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers) } + + let rawLayoutSize = layoutSize + if !lines.isEmpty && bottomCutoutEnabled { + let proposedWidth = lines[lines.count - 1].frame.width + bottomCutoutSize.width + if proposedWidth > layoutSize.width { + if proposedWidth <= constrainedSize.width + .ulpOfOne { + layoutSize.width = proposedWidth + } else { + layoutSize.height += bottomCutoutSize.height + } + } + } + + if lines.count < minimumNumberOfLines { + var lineCount = lines.count + while lineCount < minimumNumberOfLines { + if lineCount != 0 { + layoutSize.height += fontLineSpacing + } + layoutSize.height += fontLineHeight + lineCount += 1 + } + } + + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(width: ceil(layoutSize.width) + insets.left + insets.right, height: ceil(layoutSize.height) + insets.top + insets.bottom), rawTextSize: CGSize(width: ceil(rawLayoutSize.width) + insets.left + insets.right, height: ceil(rawLayoutSize.height) + insets.top + insets.bottom), truncated: truncated, firstLineOffset: firstLineOffset, lines: lines, blockQuotes: blockQuotes, backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displaySpoilers: displaySpoilers) } override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { @@ -1584,6 +1964,46 @@ open class TextNode: ASDisplayNode { context.fill(bounds) } + let alignment = layout.resolvedAlignment + var offset = CGPoint(x: layout.insets.left, y: layout.insets.top) + switch layout.verticalAlignment { + case .top: + break + case .middle: + offset.y = floor((bounds.height - layout.size.height) / 2.0) + layout.insets.top + case .bottom: + offset.y = floor(bounds.height - layout.size.height) + layout.insets.top + } + + if !layout.lines.isEmpty { + offset.y += layout.lines[0].descent + } + + for blockQuote in layout.blockQuotes { + let radius: CGFloat = 3.0 + + let blockFrame = blockQuote.frame.offsetBy(dx: offset.x + 2.0, dy: offset.y) + + context.setFillColor(blockQuote.tintColor.withMultipliedAlpha(0.1).cgColor) + context.addPath(UIBezierPath(roundedRect: blockFrame, cornerRadius: radius).cgPath) + context.fillPath() + + context.setFillColor(blockQuote.tintColor.cgColor) + + let quoteRect = CGRect(origin: CGPoint(x: blockFrame.maxX - 4.0 - quoteIcon.size.width, y: blockFrame.minY + 4.0), size: quoteIcon.size) + context.clip(to: quoteRect, mask: quoteIcon.cgImage!) + context.fill(quoteRect) + context.resetClip() + + let lineFrame = CGRect(origin: CGPoint(x: blockFrame.minX, y: blockFrame.minY), size: CGSize(width: radius, height: blockFrame.height)) + context.move(to: CGPoint(x: lineFrame.minX, y: lineFrame.minY + radius)) + context.addArc(tangent1End: CGPoint(x: lineFrame.minX, y: lineFrame.minY), tangent2End: CGPoint(x: lineFrame.minX + radius, y: lineFrame.minY), radius: radius) + context.addLine(to: CGPoint(x: lineFrame.minX + radius, y: lineFrame.maxY)) + context.addArc(tangent1End: CGPoint(x: lineFrame.minX, y: lineFrame.maxY), tangent2End: CGPoint(x: lineFrame.minX, y: lineFrame.maxY - radius), radius: radius) + context.closePath() + context.fillPath() + } + if let textShadowColor = layout.textShadowColor { context.setTextDrawingMode(.fill) context.setShadow(offset: layout.textShadowBlur != nil ? .zero : CGSize(width: 0.0, height: 1.0), blur: layout.textShadowBlur ?? 0.0, color: textShadowColor.cgColor) @@ -1605,17 +2025,6 @@ open class TextNode: ASDisplayNode { let textPosition = context.textPosition context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0) - let alignment = layout.resolvedAlignment - var offset = CGPoint(x: layout.insets.left, y: layout.insets.top) - switch layout.verticalAlignment { - case .top: - break - case .middle: - offset.y = floor((bounds.height - layout.size.height) / 2.0) + layout.insets.top - case .bottom: - offset.y = floor(bounds.height - layout.size.height) + layout.insets.top - } - for i in 0 ..< layout.lines.count { let line = layout.lines[i] @@ -1632,6 +2041,12 @@ open class TextNode: ASDisplayNode { lineFrame.origin.x += offset.x } } + + //context.setStrokeColor(UIColor.red.cgColor) + //context.stroke(lineFrame.offsetBy(dx: 0.0, dy: -lineFrame.height)) + + lineFrame.origin.y += -line.descent + context.textPosition = CGPoint(x: lineFrame.minX, y: lineFrame.minY) if layout.displaySpoilers && !line.spoilers.isEmpty { @@ -1688,8 +2103,11 @@ open class TextNode: ASDisplayNode { if !line.strikethroughs.isEmpty { for strikethrough in line.strikethroughs { + guard let lineRange = line.range else { + continue + } var textColor: UIColor? - layout.attributedString?.enumerateAttributes(in: NSMakeRange(line.range.location, line.range.length), options: []) { attributes, range, _ in + layout.attributedString?.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in if range == strikethrough.range, let color = attributes[NSAttributedString.Key.foregroundColor] as? UIColor { textColor = color } @@ -1760,34 +2178,6 @@ open class TextNode: ASDisplayNode { } } - var blockQuoteFrames: [CGRect] = [] - var currentBlockQuoteFrame: CGRect? - for blockQuote in layout.blockQuotes { - if let frame = currentBlockQuoteFrame { - if blockQuote.frame.minY - frame.maxY < 20.0 { - currentBlockQuoteFrame = frame.union(blockQuote.frame) - } else { - blockQuoteFrames.append(frame) - currentBlockQuoteFrame = frame - } - } else { - currentBlockQuoteFrame = blockQuote.frame - } - } - - if let frame = currentBlockQuoteFrame { - blockQuoteFrames.append(frame) - } - - for frame in blockQuoteFrames { - if let lineColor = layout.lineColor { - context.setFillColor(lineColor.cgColor) - } - let rect = UIBezierPath(roundedRect: CGRect(x: frame.minX - 9.0, y: frame.minY - 14.0, width: 2.0, height: frame.height), cornerRadius: 1.0) - context.addPath(rect.cgPath) - context.fillPath() - } - context.textMatrix = textMatrix context.textPosition = CGPoint(x: textPosition.x, y: textPosition.y) } @@ -1944,7 +2334,7 @@ open class TextView: UIView { let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor) var lines: [TextNodeLine] = [] - var blockQuotes: [TextNodeBlockQuote] = [] + let blockQuotes: [TextNodeBlockQuote] = [] var maybeTypesetter: CTTypesetter? maybeTypesetter = CTTypesetterCreateWithAttributedString(attributedString as CFAttributedString) @@ -2175,13 +2565,15 @@ open class TextView: UIView { } } - let lineWidth = min(lineConstrainedSize.width, ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine)))) + var lineAscent: CGFloat = 0.0 + var lineDescent: CGFloat = 0.0 + let lineWidth = min(lineConstrainedSize.width, ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, &lineAscent, &lineDescent, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine)))) let lineFrame = CGRect(x: lineCutoutOffset + headIndent, y: lineOriginY, width: lineWidth, height: fontLineHeight) layoutSize.height += fontLineHeight + fontLineSpacing layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth) if headIndent > 0.0 { - blockQuotes.append(TextNodeBlockQuote(frame: lineFrame)) + //blockQuotes.append(TextNodeBlockQuote(frame: lineFrame)) } var isRTL = false @@ -2193,7 +2585,20 @@ open class TextView: UIView { } } - lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length), isRTL: isRTL, strikethroughs: strikethroughs, spoilers: spoilers, spoilerWords: spoilerWords, embeddedItems: [], attachments: attachments, additionalTrailingLine: nil)) + lines.append(TextNodeLine( + line: coreTextLine, + frame: lineFrame, + ascent: lineAscent, + descent: lineDescent, + range: NSMakeRange(lineRange.location, lineRange.length), + isRTL: isRTL, + strikethroughs: strikethroughs, + spoilers: spoilers, + spoilerWords: spoilerWords, + embeddedItems: [], + attachments: attachments, + additionalTrailingLine: nil + )) break } else { if lineCharacterCount > 0 { @@ -2263,13 +2668,15 @@ open class TextView: UIView { } } - let lineWidth = ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine))) + var lineAscent: CGFloat = 0.0 + var lineDescent: CGFloat = 0.0 + let lineWidth = ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, &lineAscent, &lineDescent, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine))) let lineFrame = CGRect(x: lineCutoutOffset + headIndent, y: lineOriginY, width: lineWidth, height: fontLineHeight) layoutSize.height += fontLineHeight layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth) if headIndent > 0.0 { - blockQuotes.append(TextNodeBlockQuote(frame: lineFrame)) + //blockQuotes.append(TextNodeBlockQuote(frame: lineFrame)) } var isRTL = false @@ -2281,7 +2688,20 @@ open class TextView: UIView { } } - lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length), isRTL: isRTL, strikethroughs: strikethroughs, spoilers: spoilers, spoilerWords: spoilerWords, embeddedItems: [], attachments: attachments, additionalTrailingLine: nil)) + lines.append(TextNodeLine( + line: coreTextLine, + frame: lineFrame, + ascent: lineAscent, + descent: lineDescent, + range: NSMakeRange(lineRange.location, lineRange.length), + isRTL: isRTL, + strikethroughs: strikethroughs, + spoilers: spoilers, + spoilerWords: spoilerWords, + embeddedItems: [], + attachments: attachments, + additionalTrailingLine: nil + )) } else { if !lines.isEmpty { layoutSize.height += fontLineSpacing diff --git a/submodules/Display/Source/ViewController.swift b/submodules/Display/Source/ViewController.swift index 5eaecf3ab4..7a7fecd24f 100644 --- a/submodules/Display/Source/ViewController.swift +++ b/submodules/Display/Source/ViewController.swift @@ -227,6 +227,10 @@ public protocol CustomViewControllerNavigationDataSummary: AnyObject { } private var navigationBarOrigin: CGFloat = 0.0 + + open var interactiveNavivationGestureEdgeWidth: InteractiveTransitionGestureRecognizerEdgeWidth? { + return nil + } open func navigationLayout(layout: ContainerViewLayout) -> NavigationLayout { let statusBarHeight: CGFloat = layout.statusBarHeight ?? 0.0 diff --git a/submodules/Pasteboard/Sources/Pasteboard.swift b/submodules/Pasteboard/Sources/Pasteboard.swift index b4343090bf..fb639dc29a 100644 --- a/submodules/Pasteboard/Sources/Pasteboard.swift +++ b/submodules/Pasteboard/Sources/Pasteboard.swift @@ -64,6 +64,9 @@ private func chatInputStateString(attributedString: NSAttributedString) -> NSAtt if let value = attributes[ChatTextInputAttributes.customEmoji] as? ChatTextInputTextCustomEmojiAttribute { string.addAttribute(ChatTextInputAttributes.customEmoji, value: value, range: range) } + if let value = attributes[ChatTextInputAttributes.quote] as? ChatTextInputTextQuoteAttribute { + string.addAttribute(ChatTextInputAttributes.quote, value: value, range: range) + } }) return string } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Data/TelegramEngineData.swift b/submodules/TelegramCore/Sources/TelegramEngine/Data/TelegramEngineData.swift index 53924d3223..87d5c3390f 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Data/TelegramEngineData.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Data/TelegramEngineData.swift @@ -155,6 +155,9 @@ public extension TelegramEngine { return results } } + + /*public func subscribe(_ ts: repeat each T) -> Signal { + }*/ public func subscribe(_ t0: T0) -> Signal { return self._subscribe(items: [t0 as! AnyPostboxViewDataItem]) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD index 0776295c16..77ed3bbffc 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD @@ -34,6 +34,8 @@ swift_library( "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", "//submodules/TelegramUI/Components/Chat/ShimmeringLinkNode", "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", + "//submodules/TelegramUI/Components/Chat/MessageQuoteComponent", + "//submodules/TelegramUI/Components/RichTextView", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index d50732b02d..a39f6a6b9f 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -24,6 +24,7 @@ import ChatMessageDateAndStatusNode import ChatMessageBubbleContentNode import ShimmeringLinkNode import ChatMessageItemCommon +import RichTextView private final class CachedChatMessageText { let text: String @@ -343,7 +344,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { let textFont = item.presentationData.messageFont if let entities = entities { - attributedText = stringWithAppliedEntities(rawText, entities: entities, baseColor: messageTheme.primaryTextColor, linkColor: messageTheme.linkTextColor, baseFont: textFont, linkFont: textFont, boldFont: item.presentationData.messageBoldFont, italicFont: item.presentationData.messageItalicFont, boldItalicFont: item.presentationData.messageBoldItalicFont, fixedFont: item.presentationData.messageFixedFont, blockQuoteFont: item.presentationData.messageBlockQuoteFont, message: item.message) + attributedText = stringWithAppliedEntities(rawText, entities: entities, baseColor: messageTheme.primaryTextColor, linkColor: messageTheme.linkTextColor, baseQuoteTintColor: messageTheme.accentControlColor, baseFont: textFont, linkFont: textFont, boldFont: item.presentationData.messageBoldFont, italicFont: item.presentationData.messageItalicFont, boldItalicFont: item.presentationData.messageBoldItalicFont, fixedFont: item.presentationData.messageFixedFont, blockQuoteFont: item.presentationData.messageBlockQuoteFont, message: item.message) } else if !rawText.isEmpty { attributedText = NSAttributedString(string: rawText, font: textFont, textColor: messageTheme.primaryTextColor) } else { @@ -609,6 +610,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { return .bankCard(bankCard) } else if let pre = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Pre)] as? String { return .copy(pre) + } else if let code = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Code)] as? String { + return .copy(code) } else if let emoji = attributes[NSAttributedString.Key(rawValue: ChatTextInputAttributes.customEmoji.rawValue)] as? ChatTextInputTextCustomEmojiAttribute, let file = emoji.file { return .customEmoji(file) } else { diff --git a/submodules/TelegramUI/Components/Chat/MessageQuoteComponent/BUILD b/submodules/TelegramUI/Components/Chat/MessageQuoteComponent/BUILD new file mode 100644 index 0000000000..0145b88d21 --- /dev/null +++ b/submodules/TelegramUI/Components/Chat/MessageQuoteComponent/BUILD @@ -0,0 +1,21 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "MessageQuoteComponent", + module_name = "MessageQuoteComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/ComponentFlow", + "//submodules/TelegramPresentationData", + "//submodules/TelegramCore", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Chat/MessageQuoteComponent/Sources/MessageQuoteComponent.swift b/submodules/TelegramUI/Components/Chat/MessageQuoteComponent/Sources/MessageQuoteComponent.swift new file mode 100644 index 0000000000..b459705bc6 --- /dev/null +++ b/submodules/TelegramUI/Components/Chat/MessageQuoteComponent/Sources/MessageQuoteComponent.swift @@ -0,0 +1,67 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import TelegramPresentationData +import TelegramCore + +private let lineImage: UIImage = { + let radius: CGFloat = 4.0 + return generateImage(CGSize(width: radius, height: radius * 2.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor.white.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: radius * 2.0, height: radius * 2.0))) + })!.stretchableImage(withLeftCapWidth: 0, topCapHeight: Int(radius)).withRenderingMode(.alwaysTemplate) +}() + +public final class MessageQuoteView: UIView { + public struct Params { + let presentationData: ChatPresentationData + let authorName: String? + let text: String + let entities: [MessageTextEntity] + + public init( + presentationData: ChatPresentationData, + authorName: String?, + text: String, + entities: [MessageTextEntity] + ) { + self.presentationData = presentationData + self.authorName = authorName + self.text = text + self.entities = entities + } + } + + private let lineView: UIImageView + + override private init(frame: CGRect) { + self.lineView = UIImageView() + self.lineView.image = lineImage + + super.init(frame: frame) + + self.addSubview(self.lineView) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public static func asyncLayout(_ view: MessageQuoteView?) -> (Params) -> (CGSize, (CGSize) -> MessageQuoteView) { + return { params in + var minSize = CGSize() + + minSize.height = 100.0 + + return (minSize, { size in + let view = view ?? MessageQuoteView(frame: CGRect()) + + view.lineView.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: lineImage.size.width, height: size.height)) + + return view + }) + } + } +} diff --git a/submodules/TelegramUI/Components/RichTextView/BUILD b/submodules/TelegramUI/Components/RichTextView/BUILD new file mode 100644 index 0000000000..0355bdfea0 --- /dev/null +++ b/submodules/TelegramUI/Components/RichTextView/BUILD @@ -0,0 +1,17 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "RichTextView", + module_name = "RichTextView", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/RichTextView/Sources/RichTextView.swift b/submodules/TelegramUI/Components/RichTextView/Sources/RichTextView.swift new file mode 100644 index 0000000000..214fcc7e49 --- /dev/null +++ b/submodules/TelegramUI/Components/RichTextView/Sources/RichTextView.swift @@ -0,0 +1,82 @@ +import Foundation +import UIKit + +public final class RichTextView: UIView { + public final class Params: Equatable { + let string: NSAttributedString + let constrainedSize: CGSize + + public init( + string: NSAttributedString, + constrainedSize: CGSize + ) { + self.string = string + self.constrainedSize = constrainedSize + } + + public static func ==(lhs: Params, rhs: Params) -> Bool { + if !lhs.string.isEqual(to: rhs.string) { + return false + } + if lhs.constrainedSize != rhs.constrainedSize { + return false + } + return true + } + } + + public final class LayoutData: Equatable { + init() { + } + + public static func ==(lhs: LayoutData, rhs: LayoutData) -> Bool { + return true + } + } + + public final class AsyncResult { + public let view: () -> RichTextView + public let layoutData: LayoutData + + init(view: @escaping () -> RichTextView, layoutData: LayoutData) { + self.view = view + self.layoutData = layoutData + } + } + + private static func performLayout(params: Params) -> LayoutData { + return LayoutData() + } + + public static func updateAsync(_ view: RichTextView?) -> (Params) -> AsyncResult { + return { params in + let layoutData = performLayout(params: params) + + return AsyncResult( + view: { + let view = view ?? RichTextView(frame: CGRect()) + view.layoutData = layoutData + return view + }, + layoutData: layoutData + ) + } + } + + private var layoutData: LayoutData? + + override public init(frame: CGRect) { + super.init(frame: frame) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func draw(_ rect: CGRect) { + guard let layoutData = self.layoutData else { + return + } + let _ = layoutData + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Message/ReplyQuoteIcon.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Message/ReplyQuoteIcon.imageset/Contents.json new file mode 100644 index 0000000000..922ad2ecb0 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Message/ReplyQuoteIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "quotemini.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Message/ReplyQuoteIcon.imageset/quotemini.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Message/ReplyQuoteIcon.imageset/quotemini.pdf new file mode 100644 index 0000000000..15b20f4560 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Message/ReplyQuoteIcon.imageset/quotemini.pdf @@ -0,0 +1,158 @@ +%PDF-1.7 + +1 0 obj + << /Type /XObject + /Length 2 0 R + /Group << /Type /Group + /S /Transparency + >> + /Subtype /Form + /Resources << >> + /BBox [ 0.000000 0.000000 9.000000 7.000244 ] + >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 -0.141113 cm +0.000000 0.000000 0.000000 scn +0.000000 5.141357 m +0.000000 6.245927 0.895431 7.141357 2.000000 7.141357 c +3.104569 7.141357 4.000000 6.245927 4.000000 5.141357 c +4.000000 4.557765 l +4.000000 3.471050 3.746984 2.399258 3.260991 1.427270 c +2.894427 0.694144 l +2.647438 0.200165 2.046765 -0.000059 1.552786 0.246930 c +1.058808 0.493919 0.858584 1.094593 1.105573 1.588571 c +1.472136 2.321697 l +1.605720 2.588866 1.714662 2.866591 1.798144 3.151417 c +0.788369 3.252621 0.000000 4.104923 0.000000 5.141357 c +h +5.000000 5.141357 m +5.000000 6.245927 5.895431 7.141357 7.000000 7.141357 c +8.104569 7.141357 9.000000 6.245927 9.000000 5.141357 c +9.000000 4.557765 l +9.000000 3.471050 8.746984 2.399258 8.260990 1.427270 c +7.894427 0.694144 l +7.647438 0.200165 7.046765 -0.000059 6.552786 0.246930 c +6.058808 0.493919 5.858583 1.094593 6.105573 1.588571 c +6.472136 2.321697 l +6.605721 2.588866 6.714662 2.866591 6.798144 3.151417 c +5.788369 3.252621 5.000000 4.104923 5.000000 5.141357 c +h +f* +n +Q + +endstream +endobj + +2 0 obj + 1077 +endobj + +3 0 obj + << /Type /XObject + /Length 4 0 R + /Group << /Type /Group + /S /Transparency + >> + /Subtype /Form + /Resources << >> + /BBox [ 0.000000 0.000000 9.000000 7.000244 ] + >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +0.000000 0.000000 0.000000 scn +0.000000 7.000244 m +9.000000 7.000244 l +9.000000 0.000025 l +0.000000 0.000025 l +0.000000 7.000244 l +h +f +n +Q + +endstream +endobj + +4 0 obj + 227 +endobj + +5 0 obj + << /XObject << /X1 1 0 R >> + /ExtGState << /E1 << /SMask << /Type /Mask + /G 3 0 R + /S /Alpha + >> + /Type /ExtGState + >> >> + >> +endobj + +6 0 obj + << /Length 7 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +/E1 gs +/X1 Do +Q + +endstream +endobj + +7 0 obj + 46 +endobj + +8 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 9.000000 7.000244 ] + /Resources 5 0 R + /Contents 6 0 R + /Parent 9 0 R + >> +endobj + +9 0 obj + << /Kids [ 8 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +10 0 obj + << /Pages 9 0 R + /Type /Catalog + >> +endobj + +xref +0 11 +0000000000 65535 f +0000000010 00000 n +0000001333 00000 n +0000001356 00000 n +0000001829 00000 n +0000001851 00000 n +0000002149 00000 n +0000002251 00000 n +0000002272 00000 n +0000002443 00000 n +0000002517 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 10 0 R + /Size 11 +>> +startxref +2577 +%%EOF \ No newline at end of file diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 80b1707722..773433e953 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -529,6 +529,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } + override public var interactiveNavivationGestureEdgeWidth: InteractiveTransitionGestureRecognizerEdgeWidth? { + return .widthMultiplier(factor: 0.35, min: 16.0, max: 200.0) + } + private var scheduledScrollToMessageId: (MessageId, Double?)? public var purposefulAction: (() -> Void)? diff --git a/submodules/TelegramUI/Sources/ChatDateSelectionSheet.swift b/submodules/TelegramUI/Sources/ChatDateSelectionSheet.swift deleted file mode 100644 index 658c10305e..0000000000 --- a/submodules/TelegramUI/Sources/ChatDateSelectionSheet.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Foundation -import UIKit -import Display -import AsyncDisplayKit -import UIKit -import SwiftSignalKit -import Photos -import TelegramPresentationData -import UIKitRuntimeUtils - -final class ChatDateSelectionSheet: ActionSheetController { - private let strings: PresentationStrings - - private let _ready = Promise() - override var ready: Promise { - return self._ready - } - - init(presentationData: PresentationData, completion: @escaping (Int32) -> Void) { - self.strings = presentationData.strings - - super.init(theme: ActionSheetControllerTheme(presentationData: presentationData)) - - self._ready.set(.single(true)) - - var updatedValue: Int32? - self.setItemGroups([ - ActionSheetItemGroup(items: [ - ChatDateSelectorItem(strings: self.strings, valueChanged: { value in - updatedValue = value - }), - ActionSheetButtonItem(title: self.strings.Common_Search, action: { [weak self] in - self?.dismissAnimated() - if let updatedValue = updatedValue { - completion(updatedValue) - } - }) - ]), - ActionSheetItemGroup(items: [ - ActionSheetButtonItem(title: self.strings.Common_Cancel, action: { [weak self] in - self?.dismissAnimated() - }), - ]) - ]) - } - - required init(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -private final class ChatDateSelectorItem: ActionSheetItem { - let strings: PresentationStrings - - let valueChanged: (Int32) -> Void - - init(strings: PresentationStrings, valueChanged: @escaping (Int32) -> Void) { - self.strings = strings - self.valueChanged = valueChanged - } - - func node(theme: ActionSheetControllerTheme) -> ActionSheetItemNode { - return ChatDateSelectorItemNode(theme: theme, strings: self.strings, valueChanged: self.valueChanged) - } - - func updateNode(_ node: ActionSheetItemNode) { - } -} - -private final class ChatDateSelectorItemNode: ActionSheetItemNode { - private let theme: ActionSheetControllerTheme - private let strings: PresentationStrings - - private let pickerView: UIDatePicker - - private let valueChanged: (Int32) -> Void - - private var currentValue: Int32 { - return Int32(self.pickerView.date.timeIntervalSince1970) - } - - init(theme: ActionSheetControllerTheme, strings: PresentationStrings, valueChanged: @escaping (Int32) -> Void) { - self.theme = theme - self.strings = strings - self.valueChanged = valueChanged - - UILabel.setDateLabel(theme.primaryTextColor) - - self.pickerView = UIDatePicker() - self.pickerView.datePickerMode = .countDownTimer - self.pickerView.datePickerMode = .date - self.pickerView.locale = Locale(identifier: strings.baseLanguageCode) - - self.pickerView.minimumDate = Date(timeIntervalSince1970: 1376438400.0) - self.pickerView.maximumDate = Date(timeIntervalSinceNow: 2.0) - - if #available(iOS 13.4, *) { - self.pickerView.preferredDatePickerStyle = .wheels - } - - self.pickerView.setValue(theme.primaryTextColor, forKey: "textColor") - self.pickerView.setValue(theme.primaryTextColor, forKey: "highlightColor") - - super.init(theme: theme) - - self.view.addSubview(self.pickerView) - self.pickerView.addTarget(self, action: #selector(self.pickerChanged), for: .valueChanged) - } - - public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { - let size = CGSize(width: constrainedSize.width, height: 157.0) - - self.pickerView.frame = CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: 180.0)) - - self.updateInternalLayout(size, constrainedSize: constrainedSize) - return size - } - - @objc func pickerChanged() { - self.valueChanged(self.currentValue) - } -} diff --git a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift index 6b3f7e1909..87a1a70247 100644 --- a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift @@ -3568,7 +3568,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { return ASEditableTextNodeTargetForAction(target: nil) } } - } else if action == #selector(self.formatAttributesBold(_:)) || action == #selector(self.formatAttributesItalic(_:)) || action == #selector(self.formatAttributesMonospace(_:)) || action == #selector(self.formatAttributesLink(_:)) || action == #selector(self.formatAttributesStrikethrough(_:)) || action == #selector(self.formatAttributesUnderline(_:)) || action == #selector(self.formatAttributesSpoiler(_:)) { + } else if action == #selector(self.formatAttributesBold(_:)) || action == #selector(self.formatAttributesItalic(_:)) || action == #selector(self.formatAttributesMonospace(_:)) || action == #selector(self.formatAttributesLink(_:)) || action == #selector(self.formatAttributesStrikethrough(_:)) || action == #selector(self.formatAttributesUnderline(_:)) || action == #selector(self.formatAttributesSpoiler(_:)) || action == #selector(self.formatAttributesQuote(_:)) { if case .format = self.inputMenu.state { if action == #selector(self.formatAttributesSpoiler(_:)), let selectedRange = self.textInputNode?.selectedRange { var intersectsMonospace = false @@ -3582,6 +3582,9 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { } else { return ASEditableTextNodeTargetForAction(target: nil) } + } else if action == #selector(self.formatAttributesQuote(_:)), let selectedRange = self.textInputNode?.selectedRange { + let _ = selectedRange + return ASEditableTextNodeTargetForAction(target: self) } else if action == #selector(self.formatAttributesMonospace(_:)), let selectedRange = self.textInputNode?.selectedRange { var intersectsSpoiler = false self.inputTextState.inputText.enumerateAttributes(in: selectedRange, options: [], using: { attributes, _, _ in @@ -3614,7 +3617,29 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { if editableTextNode.attributedText == nil || editableTextNode.attributedText!.length == 0 || editableTextNode.selectedRange.length == 0 { } else { - var children: [UIAction] = [ + var children: [UIAction] = [] + + //TODO:localize + children.append(UIAction(title: "Quote", image: nil) { [weak self] (action) in + if let strongSelf = self { + strongSelf.formatAttributesQuote(strongSelf) + } + }) + + var hasSpoilers = true + if self.presentationInterfaceState?.chatLocation.peerId?.namespace == Namespaces.Peer.SecretChat { + hasSpoilers = false + } + + if hasSpoilers { + children.append(UIAction(title: self.strings?.TextFormat_Spoiler ?? "Spoiler", image: nil) { [weak self] (action) in + if let strongSelf = self { + strongSelf.formatAttributesSpoiler(strongSelf) + } + }) + } + + children.append(contentsOf: [ UIAction(title: self.strings?.TextFormat_Bold ?? "Bold", image: nil) { [weak self] (action) in if let strongSelf = self { strongSelf.formatAttributesBold(strongSelf) @@ -3645,20 +3670,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { strongSelf.formatAttributesUnderline(strongSelf) } } - ] - - var hasSpoilers = true - if self.presentationInterfaceState?.chatLocation.peerId?.namespace == Namespaces.Peer.SecretChat { - hasSpoilers = false - } - - if hasSpoilers { - children.append(UIAction(title: self.strings?.TextFormat_Spoiler ?? "Spoiler", image: nil) { [weak self] (action) in - if let strongSelf = self { - strongSelf.formatAttributesSpoiler(strongSelf) - } - }) - } + ] as [UIAction]) let formatMenu = UIMenu(title: self.strings?.TextFormat_Format ?? "Format", image: nil, children: children) actions.insert(formatMenu, at: 3) @@ -3737,6 +3749,14 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { } } + @objc func formatAttributesQuote(_ sender: Any) { + self.inputMenu.back() + + self.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in + return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.quote), inputMode) + } + } + @objc func formatAttributesSpoiler(_ sender: Any) { self.inputMenu.back() diff --git a/submodules/TextFormat/Sources/ChatTextInputAttributes.swift b/submodules/TextFormat/Sources/ChatTextInputAttributes.swift index 494c70e470..e18c83cb7a 100644 --- a/submodules/TextFormat/Sources/ChatTextInputAttributes.swift +++ b/submodules/TextFormat/Sources/ChatTextInputAttributes.swift @@ -19,8 +19,10 @@ public struct ChatTextInputAttributes { public static let textUrl = NSAttributedString.Key(rawValue: "Attribute__TextUrl") public static let spoiler = NSAttributedString.Key(rawValue: "Attribute__Spoiler") public static let customEmoji = NSAttributedString.Key(rawValue: "Attribute__CustomEmoji") + public static let code = NSAttributedString.Key(rawValue: "Attribute__Code") + public static let quote = NSAttributedString.Key(rawValue: "Attribute__Blockquote") - public static let allAttributes = [ChatTextInputAttributes.bold, ChatTextInputAttributes.italic, ChatTextInputAttributes.monospace, ChatTextInputAttributes.strikethrough, ChatTextInputAttributes.underline, ChatTextInputAttributes.textMention, ChatTextInputAttributes.textUrl, ChatTextInputAttributes.spoiler, ChatTextInputAttributes.customEmoji] + public static let allAttributes = [ChatTextInputAttributes.bold, ChatTextInputAttributes.italic, ChatTextInputAttributes.monospace, ChatTextInputAttributes.strikethrough, ChatTextInputAttributes.underline, ChatTextInputAttributes.textMention, ChatTextInputAttributes.textUrl, ChatTextInputAttributes.spoiler, ChatTextInputAttributes.customEmoji, ChatTextInputAttributes.code, ChatTextInputAttributes.quote] } public let originalTextAttributeKey = NSAttributedString.Key(rawValue: "Attribute__OriginalText") @@ -115,6 +117,13 @@ public func textAttributedStringForStateText(_ stateText: NSAttributedString, fo } else if key == ChatTextInputAttributes.customEmoji { result.addAttribute(key, value: value, range: range) result.addAttribute(NSAttributedString.Key.foregroundColor, value: UIColor.clear, range: range) + } else if key == ChatTextInputAttributes.quote { + result.addAttribute(key, value: value, range: range) + result.addAttribute(NSAttributedString.Key.backgroundColor, value: accentTextColor.withAlphaComponent(0.15), range: range) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.headIndent = 8.0 + paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: paragraphStyle.headIndent, options: [:])] + result.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: range) } } @@ -193,6 +202,22 @@ public final class ChatTextInputTextUrlAttribute: NSObject { } } +public final class ChatTextInputTextQuoteAttribute: NSObject { + override public init() { + super.init() + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? ChatTextInputTextQuoteAttribute else { + return false + } + + let _ = other + + return true + } +} + public final class ChatTextInputTextCustomEmojiAttribute: NSObject, Codable { private enum CodingKeys: String, CodingKey { case interactivelySelectedFromPackId @@ -506,6 +531,135 @@ private func refreshTextUrls(text: NSString, initialAttributedText: NSAttributed } } +private func quoteRangesEqual(_ lhs: [(NSRange, ChatTextInputTextQuoteAttribute)], _ rhs: [(NSRange, ChatTextInputTextQuoteAttribute)]) -> Bool { + if lhs.count != rhs.count { + return false + } + for i in 0 ..< lhs.count { + if lhs[i].0 != rhs[i].0 || !lhs[i].1.isEqual(rhs[i].1) { + return false + } + } + return true +} + +private func refreshBlockQuotes(text: NSString, initialAttributedText: NSAttributedString, attributedText: NSMutableAttributedString, fullRange: NSRange) { + var quoteRanges: [(NSRange, ChatTextInputTextQuoteAttribute)] = [] + initialAttributedText.enumerateAttribute(ChatTextInputAttributes.quote, in: fullRange, options: [], using: { value, range, _ in + if let value = value as? ChatTextInputTextQuoteAttribute { + quoteRanges.append((range, value)) + } + }) + quoteRanges.sort(by: { $0.0.location < $1.0.location }) + let initialQuoteRanges = quoteRanges + + for i in 0 ..< quoteRanges.count { + let range = quoteRanges[i].0 + + var validLower = range.lowerBound + inner1: for i in range.lowerBound ..< range.upperBound { + if let c = UnicodeScalar(text.character(at: i)) { + if textUrlCharacters.contains(c) { + validLower = i + break inner1 + } + } else { + break inner1 + } + } + var validUpper = range.upperBound + inner2: for i in (validLower ..< range.upperBound).reversed() { + if let c = UnicodeScalar(text.character(at: i)) { + if textUrlCharacters.contains(c) { + validUpper = i + 1 + break inner2 + } + } else { + break inner2 + } + } + + let minLower = (i == 0) ? fullRange.lowerBound : quoteRanges[i - 1].0.upperBound + inner3: for i in (minLower ..< validLower).reversed() { + if let c = UnicodeScalar(text.character(at: i)) { + if textUrlEdgeCharacters.contains(c) { + validLower = i + } else { + break inner3 + } + } else { + break inner3 + } + } + + let maxUpper = (i == quoteRanges.count - 1) ? fullRange.upperBound : quoteRanges[i + 1].0.lowerBound + inner3: for i in validUpper ..< maxUpper { + if let c = UnicodeScalar(text.character(at: i)) { + if textUrlEdgeCharacters.contains(c) { + validUpper = i + 1 + } else { + break inner3 + } + } else { + break inner3 + } + } + + quoteRanges[i] = (NSRange(location: validLower, length: validUpper - validLower), quoteRanges[i].1) + } + + quoteRanges = quoteRanges.filter({ $0.0.length > 0 }) + + while quoteRanges.count > 1 { + var hadReductions = false + outer: for i in 0 ..< quoteRanges.count - 1 { + if quoteRanges[i].1 === quoteRanges[i + 1].1 { + var combine = true + inner: for j in quoteRanges[i].0.upperBound ..< quoteRanges[i + 1].0.lowerBound { + if let c = UnicodeScalar(text.character(at: j)) { + if textUrlCharacters.contains(c) { + } else { + combine = false + break inner + } + } else { + combine = false + break inner + } + } + if combine { + hadReductions = true + quoteRanges[i] = (NSRange(location: quoteRanges[i].0.lowerBound, length: quoteRanges[i + 1].0.upperBound - quoteRanges[i].0.lowerBound), quoteRanges[i].1) + quoteRanges.remove(at: i + 1) + break outer + } + } + } + if !hadReductions { + break + } + } + + if quoteRanges.count > 1 { + outer: for i in (1 ..< quoteRanges.count).reversed() { + for j in 0 ..< i { + if quoteRanges[j].1 === quoteRanges[i].1 { + quoteRanges.remove(at: i) + continue outer + } + } + } + } + + if !quoteRangesEqual(quoteRanges, initialQuoteRanges) { + attributedText.removeAttribute(ChatTextInputAttributes.quote, range: fullRange) + for (range, attribute) in quoteRanges { + let _ = attribute + attributedText.addAttribute(ChatTextInputAttributes.quote, value: ChatTextInputTextQuoteAttribute(), range: range) + } + } +} + public func refreshChatTextInputAttributes(_ textNode: ASEditableTextNode, theme: PresentationTheme, baseFontSize: CGFloat, spoilersRevealed: Bool, availableEmojis: Set, emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?) { refreshChatTextInputAttributes(textView: textNode.textView, primaryTextColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor, baseFontSize: baseFontSize, spoilersRevealed: spoilersRevealed, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider) } @@ -534,6 +688,13 @@ public func refreshChatTextInputAttributes(textView: UITextView, primaryTextColo resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: primaryTextColor, accentTextColor: accentTextColor, writingDirection: writingDirection, spoilersRevealed: spoilersRevealed, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider) + text = resultAttributedText.string as NSString + fullRange = NSRange(location: 0, length: text.length) + attributedText = NSMutableAttributedString(attributedString: stateAttributedStringForText(resultAttributedText)) + refreshBlockQuotes(text: text, initialAttributedText: resultAttributedText, attributedText: attributedText, fullRange: fullRange) + + resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: primaryTextColor, accentTextColor: accentTextColor, writingDirection: writingDirection, spoilersRevealed: spoilersRevealed, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider) + if !resultAttributedText.isEqual(to: initialAttributedText) { fullRange = NSRange(location: 0, length: textView.textStorage.length) @@ -546,6 +707,7 @@ public func refreshChatTextInputAttributes(textView: UITextView, primaryTextColo textView.textStorage.removeAttribute(ChatTextInputAttributes.textUrl, range: fullRange) textView.textStorage.removeAttribute(ChatTextInputAttributes.spoiler, range: fullRange) textView.textStorage.removeAttribute(ChatTextInputAttributes.customEmoji, range: fullRange) + textView.textStorage.removeAttribute(ChatTextInputAttributes.quote, range: fullRange) textView.textStorage.addAttribute(NSAttributedString.Key.font, value: Font.regular(baseFontSize), range: fullRange) textView.textStorage.addAttribute(NSAttributedString.Key.foregroundColor, value: primaryTextColor, range: fullRange) @@ -589,6 +751,13 @@ public func refreshChatTextInputAttributes(textView: UITextView, primaryTextColo } else if key == ChatTextInputAttributes.customEmoji, let value = value as? ChatTextInputTextCustomEmojiAttribute { textView.textStorage.addAttribute(key, value: value, range: range) textView.textStorage.addAttribute(NSAttributedString.Key.foregroundColor, value: UIColor.clear, range: range) + } else if key == ChatTextInputAttributes.quote { + textView.textStorage.addAttribute(key, value: value, range: range) + textView.textStorage.addAttribute(NSAttributedString.Key.backgroundColor, value: accentTextColor.withAlphaComponent(0.15), range: range) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.headIndent = 8.0 + paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: paragraphStyle.headIndent, options: [:])] + textView.textStorage.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: range) } } @@ -690,6 +859,12 @@ public func refreshGenericTextInputAttributes(_ textNode: ASEditableTextNode, th } else { textNode.textView.textStorage.addAttribute(NSAttributedString.Key.foregroundColor, value: UIColor.clear, range: range) } + } else if key == ChatTextInputAttributes.quote { + textNode.textView.textStorage.addAttribute(NSAttributedString.Key.backgroundColor, value: theme.chat.inputPanel.panelControlAccentColor.withAlphaComponent(0.15), range: range) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.headIndent = 8.0 + paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: paragraphStyle.headIndent, options: [:])] + textNode.textView.textStorage.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: range) } } @@ -882,7 +1057,7 @@ public func convertMarkdownToAttributes(_ text: NSAttributedString) -> NSAttribu stringOffset -= match.range(at: 2).length + match.range(at: 4).length let substring = string.substring(with: match.range(at: 1)) + text + string.substring(with: match.range(at: 5)) - result.append(NSAttributedString(string: substring, attributes: [ChatTextInputAttributes.monospace: true as NSNumber])) + result.append(NSAttributedString(string: substring, attributes: [ChatTextInputAttributes.code: true as NSNumber])) offsetRanges.append((NSMakeRange(matchIndex + match.range(at: 1).length, text.count), 6)) } } @@ -902,13 +1077,20 @@ public func convertMarkdownToAttributes(_ text: NSAttributedString) -> NSAttribu } else { let text = string.substring(with: pre) - let entity = string.substring(with: match.range(at: 7)) - let substring = string.substring(with: match.range(at: 6)) + text + string.substring(with: match.range(at: 9)) + var entity = string.substring(with: match.range(at: 7)) + var substring = string.substring(with: match.range(at: 6)) + text + string.substring(with: match.range(at: 9)) + + if entity == "`" && substring.hasPrefix("``") && substring.hasSuffix("``") { + entity = "```" + substring = String(substring[substring.index(substring.startIndex, offsetBy: 2) ..< substring.index(substring.endIndex, offsetBy: -2)]) + } let textInputAttribute: NSAttributedString.Key? switch entity { case "`": textInputAttribute = ChatTextInputAttributes.monospace + case "```": + textInputAttribute = ChatTextInputAttributes.code case "**": textInputAttribute = ChatTextInputAttributes.bold case "__": diff --git a/submodules/TextFormat/Sources/GenerateTextEntities.swift b/submodules/TextFormat/Sources/GenerateTextEntities.swift index f30fb928c5..095371186f 100644 --- a/submodules/TextFormat/Sources/GenerateTextEntities.swift +++ b/submodules/TextFormat/Sources/GenerateTextEntities.swift @@ -167,6 +167,10 @@ public func generateChatInputTextEntities(_ text: NSAttributedString, maxAnimate entities.append(MessageTextEntity(range: range.lowerBound ..< range.upperBound, type: .Spoiler)) } else if key == ChatTextInputAttributes.customEmoji, let value = value as? ChatTextInputTextCustomEmojiAttribute { entities.append(MessageTextEntity(range: range.lowerBound ..< range.upperBound, type: .CustomEmoji(stickerPack: nil, fileId: value.fileId))) + } else if key == ChatTextInputAttributes.code { + entities.append(MessageTextEntity(range: range.lowerBound ..< range.upperBound, type: .Code)) + } else if key == ChatTextInputAttributes.quote { + entities.append(MessageTextEntity(range: range.lowerBound ..< range.upperBound, type: .BlockQuote)) } } }) diff --git a/submodules/TextFormat/Sources/StringWithAppliedEntities.swift b/submodules/TextFormat/Sources/StringWithAppliedEntities.swift index a8cf179e0e..29c3108be3 100644 --- a/submodules/TextFormat/Sources/StringWithAppliedEntities.swift +++ b/submodules/TextFormat/Sources/StringWithAppliedEntities.swift @@ -2,6 +2,7 @@ import Foundation import UIKit import Postbox import TelegramCore +import Display public func chatInputStateStringWithAppliedEntities(_ text: String, entities: [MessageTextEntity]) -> NSAttributedString { var nsString: NSString? @@ -45,6 +46,8 @@ public func chatInputStateStringWithAppliedEntities(_ text: String, entities: [M string.addAttribute(ChatTextInputAttributes.spoiler, value: true as NSNumber, range: range) case let .CustomEmoji(_, fileId): string.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: nil), range: range) + case .BlockQuote: + string.addAttribute(ChatTextInputAttributes.quote, value: ChatTextInputTextQuoteAttribute(), range: range) default: break } @@ -52,7 +55,9 @@ public func chatInputStateStringWithAppliedEntities(_ text: String, entities: [M return string } -public func stringWithAppliedEntities(_ text: String, entities: [MessageTextEntity], baseColor: UIColor, linkColor: UIColor, baseFont: UIFont, linkFont: UIFont, boldFont: UIFont, italicFont: UIFont, boldItalicFont: UIFont, fixedFont: UIFont, blockQuoteFont: UIFont, underlineLinks: Bool = true, external: Bool = false, message: Message?, entityFiles: [MediaId: TelegramMediaFile] = [:]) -> NSAttributedString { +public func stringWithAppliedEntities(_ text: String, entities: [MessageTextEntity], baseColor: UIColor, linkColor: UIColor, baseQuoteTintColor: UIColor? = nil, baseFont: UIFont, linkFont: UIFont, boldFont: UIFont, italicFont: UIFont, boldItalicFont: UIFont, fixedFont: UIFont, blockQuoteFont: UIFont, underlineLinks: Bool = true, external: Bool = false, message: Message?, entityFiles: [MediaId: TelegramMediaFile] = [:]) -> NSAttributedString { + let baseQuoteTintColor = baseQuoteTintColor ?? baseColor + var nsString: NSString? let string = NSMutableAttributedString(string: text, attributes: [NSAttributedString.Key.font: baseFont, NSAttributedString.Key.foregroundColor: baseColor]) var skipEntity = false @@ -62,6 +67,8 @@ public func stringWithAppliedEntities(_ text: String, entities: [MessageTextEnti } var fontAttributes: [NSRange: ChatTextFontAttributes] = [:] + var nextBlockId = 0 + var rangeOffset: Int = 0 for i in 0 ..< entities.count { if skipEntity { @@ -197,13 +204,13 @@ public func stringWithAppliedEntities(_ text: String, entities: [MessageTextEnti nsString = text as NSString } string.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand), value: nsString!.substring(with: range), range: range) - case .Code, .Pre: + case .Pre: string.addAttribute(NSAttributedString.Key.font, value: fixedFont, range: range) if nsString == nil { nsString = text as NSString } string.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.Pre), value: nsString!.substring(with: range), range: range) - case .BlockQuote: + case .BlockQuote, .Code: if let fontAttribute = fontAttributes[range] { fontAttributes[range] = fontAttribute.union(.blockQuote) } else { @@ -211,17 +218,31 @@ public func stringWithAppliedEntities(_ text: String, entities: [MessageTextEnti } let paragraphBreak = "\n" - string.insert(NSAttributedString(string: paragraphBreak), at: range.lowerBound) - - let paragraphRange = NSRange(location: range.lowerBound + paragraphBreak.count, length: range.upperBound - range.lowerBound) - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.headIndent = 10.0 - paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: paragraphStyle.headIndent, options: [:])] - string.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: paragraphRange) + let paragraphRange: NSRange + if range.lowerBound == 0 { + paragraphRange = NSRange(location: range.lowerBound, length: range.upperBound - range.lowerBound) + } else if string.string[string.string.index(string.string.startIndex, offsetBy: range.lowerBound)] == "\n" { + paragraphRange = NSRange(location: range.lowerBound + 1, length: range.upperBound - range.lowerBound - 1) + } else if string.string[string.string.index(string.string.startIndex, offsetBy: range.lowerBound - 1)] == "\n" { + paragraphRange = NSRange(location: range.lowerBound, length: range.upperBound - range.lowerBound) + } else { + string.insert(NSAttributedString(string: paragraphBreak), at: range.lowerBound) + paragraphRange = NSRange(location: range.lowerBound + paragraphBreak.count, length: range.upperBound - range.lowerBound) + } + + string.addAttribute(NSAttributedString.Key(rawValue: "Attribute__Blockquote"), value: TextNodeBlockQuoteData(id: nextBlockId, title: nil, color: baseQuoteTintColor), range: paragraphRange) + nextBlockId += 1 - string.insert(NSAttributedString(string: paragraphBreak), at: paragraphRange.upperBound) - rangeOffset += paragraphBreak.count + if string.string.index(string.string.startIndex, offsetBy: paragraphRange.upperBound) != string.string.endIndex { + if string.string[string.string.index(string.string.startIndex, offsetBy: paragraphRange.upperBound)] == "\n" { + string.replaceCharacters(in: NSMakeRange(paragraphRange.upperBound, 1), with: "") + rangeOffset -= 1 + } + } + + rangeOffset += 0 + //rangeOffset += paragraphBreak.count case .BankCard: string.addAttribute(NSAttributedString.Key.foregroundColor, value: linkColor, range: range) if underlineLinks && underlineAllLinks { @@ -268,7 +289,9 @@ public func stringWithAppliedEntities(_ text: String, entities: [MessageTextEnti func addFont(ranges: [NSRange], fontAttributes: ChatTextFontAttributes) { for range in ranges { var font: UIFont? - if fontAttributes == [.bold, .italic] { + if fontAttributes.contains(.blockQuote) { + font = baseFont.withSize(round(baseFont.pointSize * 0.8235294117647058)) + } else if fontAttributes == [.bold, .italic] { font = boldItalicFont } else if fontAttributes == [.bold] { font = boldFont diff --git a/submodules/TextFormat/Sources/TelegramAttributes.swift b/submodules/TextFormat/Sources/TelegramAttributes.swift index ffa8508e85..1f75c378d3 100644 --- a/submodules/TextFormat/Sources/TelegramAttributes.swift +++ b/submodules/TextFormat/Sources/TelegramAttributes.swift @@ -42,4 +42,5 @@ public struct TelegramTextAttributes { public static let BlockQuote = "TelegramBlockQuote" public static let Pre = "TelegramPre" public static let Spoiler = "TelegramSpoiler" + public static let Code = "TelegramCode" }