diff --git a/Aztec.xcodeproj/project.pbxproj b/Aztec.xcodeproj/project.pbxproj index 6dedda5a2..5fade849c 100644 --- a/Aztec.xcodeproj/project.pbxproj +++ b/Aztec.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ 59FEA0781D8BDFA700D138DF /* HTMLToAttributedStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59FEA06A1D8BDFA700D138DF /* HTMLToAttributedStringTests.swift */; }; 59FEA07A1D8BDFA700D138DF /* InHTMLConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59FEA06C1D8BDFA700D138DF /* InHTMLConverterTests.swift */; }; B551A4A01E770B3800EE3A7F /* UIFont+Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = B551A49F1E770B3800EE3A7F /* UIFont+Emoji.swift */; }; + B572AC281E817CFE008948C2 /* CommentAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = B572AC271E817CFE008948C2 /* CommentAttachment.swift */; }; B577DC651E7B18E90012A1F8 /* NodeDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B577DC641E7B18E90012A1F8 /* NodeDescriptor.swift */; }; B577DC671E7B18F80012A1F8 /* CommentNodeDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B577DC661E7B18F80012A1F8 /* CommentNodeDescriptor.swift */; }; B59C9F9F1DF74BB80073B1D6 /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59C9F9E1DF74BB80073B1D6 /* UIFont+Traits.swift */; }; @@ -98,11 +99,12 @@ F1FFB2A11E6058930015ACB8 /* DOMStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1FFB2A01E6058930015ACB8 /* DOMStringTests.swift */; }; FF13CD4D1E5C8067000FF10E /* HeaderFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF13CD4C1E5C8067000FF10E /* HeaderFormatter.swift */; }; FF152D8E1E68552A00FF596C /* StringRangeConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF152D8D1E68552A00FF596C /* StringRangeConversionTests.swift */; }; - FF7A1C4F1E560A1F00C4C7C8 /* MoreAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7A1C4E1E560A1F00C4C7C8 /* MoreAttachment.swift */; }; FF7A1C511E5651EA00C4C7C8 /* LineAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7A1C501E5651EA00C4C7C8 /* LineAttachment.swift */; }; FF7C89A81E3A2B7C000472A8 /* Blockquote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7C89A71E3A2B7C000472A8 /* Blockquote.swift */; }; FF7C89AC1E3A47F1000472A8 /* StandardAttributeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7C89AB1E3A47F1000472A8 /* StandardAttributeFormatter.swift */; }; FF7C89B01E3BC52F000472A8 /* NSAttributedString+FontTraits.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7C89AF1E3BC52F000472A8 /* NSAttributedString+FontTraits.swift */; }; + FF7DCB471E80837D00AB77CB /* UIColor+Parsers.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7DCB461E80837D00AB77CB /* UIColor+Parsers.swift */; }; + FF7DCB4B1E815F9400AB77CB /* UIColorHexParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7DCB4A1E815F9400AB77CB /* UIColorHexParserTests.swift */; }; FFA61E891DF18F3D00B71BF6 /* ParagraphStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA61E881DF18F3D00B71BF6 /* ParagraphStyle.swift */; }; FFA61EC21DF6C1C900B71BF6 /* NSAttributedString+Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA61EC11DF6C1C900B71BF6 /* NSAttributedString+Archive.swift */; }; FFD0FEB71DAE59A700430586 /* NSLayoutManager+Attachments.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD0FEB61DAE59A700430586 /* NSLayoutManager+Attachments.swift */; }; @@ -180,6 +182,7 @@ 59FEA06C1D8BDFA700D138DF /* InHTMLConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InHTMLConverterTests.swift; sourceTree = ""; }; 59FEA06D1D8BDFA700D138DF /* InNodeConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InNodeConverterTests.swift; sourceTree = ""; }; B551A49F1E770B3800EE3A7F /* UIFont+Emoji.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIFont+Emoji.swift"; sourceTree = ""; }; + B572AC271E817CFE008948C2 /* CommentAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommentAttachment.swift; sourceTree = ""; }; B577DC641E7B18E90012A1F8 /* NodeDescriptor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NodeDescriptor.swift; path = Descriptors/NodeDescriptor.swift; sourceTree = ""; }; B577DC661E7B18F80012A1F8 /* CommentNodeDescriptor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = CommentNodeDescriptor.swift; path = Descriptors/CommentNodeDescriptor.swift; sourceTree = ""; }; B59C9F9E1DF74BB80073B1D6 /* UIFont+Traits.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = ""; }; @@ -234,11 +237,12 @@ FF5B98E41DC355B400571CA4 /* LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = LICENSE; sourceTree = SOURCE_ROOT; }; FF7A1C481E51EFB600C4C7C8 /* WordPress-Aztec-iOS.podspec */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "WordPress-Aztec-iOS.podspec"; sourceTree = ""; }; FF7A1C4A1E51F05700C4C7C8 /* CONTRIBUTING.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; - FF7A1C4E1E560A1F00C4C7C8 /* MoreAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoreAttachment.swift; sourceTree = ""; }; FF7A1C501E5651EA00C4C7C8 /* LineAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LineAttachment.swift; sourceTree = ""; }; FF7C89A71E3A2B7C000472A8 /* Blockquote.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Blockquote.swift; sourceTree = ""; }; FF7C89AB1E3A47F1000472A8 /* StandardAttributeFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StandardAttributeFormatter.swift; sourceTree = ""; }; FF7C89AF1E3BC52F000472A8 /* NSAttributedString+FontTraits.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+FontTraits.swift"; sourceTree = ""; }; + FF7DCB461E80837D00AB77CB /* UIColor+Parsers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Parsers.swift"; sourceTree = ""; }; + FF7DCB4A1E815F9400AB77CB /* UIColorHexParserTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = UIColorHexParserTests.swift; path = Extensions/UIColorHexParserTests.swift; sourceTree = ""; }; FFA61E881DF18F3D00B71BF6 /* ParagraphStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParagraphStyle.swift; sourceTree = ""; }; FFA61EC11DF6C1C900B71BF6 /* NSAttributedString+Archive.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Archive.swift"; sourceTree = ""; }; FFD0FEB61DAE59A700430586 /* NSLayoutManager+Attachments.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSLayoutManager+Attachments.swift"; sourceTree = ""; }; @@ -426,6 +430,7 @@ B5C99D3E1E72E2E700335355 /* UIStackView+Helpers.swift */, 599F25271D8BC9A1002871D6 /* UITextView+Helpers.swift */, F1A218141E02D5B3000AF5EB /* UndoManager.swift */, + FF7DCB461E80837D00AB77CB /* UIColor+Parsers.swift */, ); path = Extensions; sourceTree = ""; @@ -452,8 +457,8 @@ 599F252E1D8BC9A1002871D6 /* TextKit */ = { isa = PBXGroup; children = ( + B572AC271E817CFE008948C2 /* CommentAttachment.swift */, 599F25301D8BC9A1002871D6 /* TextAttachment.swift */, - FF7A1C4E1E560A1F00C4C7C8 /* MoreAttachment.swift */, FF7A1C501E5651EA00C4C7C8 /* LineAttachment.swift */, B5BC4FF51DA2D76600614582 /* TextList.swift */, 599F25311D8BC9A1002871D6 /* TextStorage.swift */, @@ -602,6 +607,7 @@ F111DF101E3BAC8E003FB794 /* NSAttributedStringAttributeRangesTests.swift */, F18733C71DA09737005AEB80 /* NSRangeComparisonTests.swift */, FF152D8D1E68552A00FF596C /* StringRangeConversionTests.swift */, + FF7DCB4A1E815F9400AB77CB /* UIColorHexParserTests.swift */, ); name = Extensions; sourceTree = ""; @@ -749,6 +755,7 @@ 599F25471D8BC9A1002871D6 /* HTMLConstants.swift in Sources */, 599F25371D8BC9A1002871D6 /* InAttributeConverter.swift in Sources */, 599F25531D8BC9A1002871D6 /* TextStorage.swift in Sources */, + B572AC281E817CFE008948C2 /* CommentAttachment.swift in Sources */, F1FA0E861E6EF514009D98EE /* Node.swift in Sources */, 599F253C1D8BC9A1002871D6 /* OutHTMLAttributeConverter.swift in Sources */, 599F254B1D8BC9A1002871D6 /* String+RangeConversion.swift in Sources */, @@ -779,12 +786,12 @@ 599F254A1D8BC9A1002871D6 /* NSAttributedString+Attachments.swift in Sources */, B5B96DAB1E01B2F300791315 /* UIPasteboard+Helpers.swift in Sources */, F17D64AE1E4230A400D09FED /* VisualOnlyAttribute.swift in Sources */, - FF7A1C4F1E560A1F00C4C7C8 /* MoreAttachment.swift in Sources */, F15C9B881DD58D8B00833C39 /* ElementNodeDescriptor.swift in Sources */, FF7C89A81E3A2B7C000472A8 /* Blockquote.swift in Sources */, 599F254C1D8BC9A1002871D6 /* UITextView+Helpers.swift in Sources */, FFA61E891DF18F3D00B71BF6 /* ParagraphStyle.swift in Sources */, F1FA0E751E6EF267009D98EE /* DOMLogic.swift in Sources */, + FF7DCB471E80837D00AB77CB /* UIColor+Parsers.swift in Sources */, FFD436961E300EF800A0E26F /* FontFormatter.swift in Sources */, F1FA0E771E6EF29B009D98EE /* DOMInspector.swift in Sources */, 599F25451D8BC9A1002871D6 /* Libxml2.swift in Sources */, @@ -824,6 +831,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FF7DCB4B1E815F9400AB77CB /* UIColorHexParserTests.swift in Sources */, 594C9D741D8BE6C700D74542 /* InNodeConverterTests.swift in Sources */, B5F84B631E706B720089A76C /* NSAttributedStringAnalyzerTests.swift in Sources */, 59FEA0751D8BDFA700D138DF /* NodeTests.swift in Sources */, diff --git a/Aztec/Classes/Converters/HTMLNodeToNSAttributedString.swift b/Aztec/Classes/Converters/HTMLNodeToNSAttributedString.swift index 47b6e15fb..508d1c098 100644 --- a/Aztec/Classes/Converters/HTMLNodeToNSAttributedString.swift +++ b/Aztec/Classes/Converters/HTMLNodeToNSAttributedString.swift @@ -97,18 +97,11 @@ class HMTLNodeToNSAttributedString: SafeConverter { /// /// - Returns: the converted node as an `NSAttributedString`. /// - fileprivate func convertCommentNode(_ node: CommentNode, inheritingAttributes inheritedAttributes: [String:Any]) -> NSAttributedString { - let moreLabel = "more" - if node.comment.hasPrefix(moreLabel) { - var attributes = inheritedAttributes; - let moreAttachment = MoreAttachment() - let index = moreLabel.endIndex - moreAttachment.message = node.comment.substring(from: index) - moreAttachment.label = NSAttributedString(string: NSLocalizedString("MORE", comment: "Text for the center of the more divider"), attributes: defaultAttributes) - attributes[NSAttachmentAttributeName] = moreAttachment - return NSAttributedString(string:String(UnicodeScalar(NSAttachmentCharacter)!), attributes: attributes) - } - return NSAttributedString(string: node.text(), attributes: inheritedAttributes) + fileprivate func convertCommentNode(_ node: CommentNode, inheritingAttributes attributes: [String:Any]) -> NSAttributedString { + let attachment = CommentAttachment() + attachment.text = node.comment + + return NSAttributedString(attachment: attachment, attributes: attributes) } /// Converts an `ElementNode` to `NSAttributedString`. @@ -211,6 +204,15 @@ class HMTLNodeToNSAttributedString: SafeConverter { attributeValue = attachment } + if node.isNodeType(.span) { + if let elementStyle = node.valueForStringAttribute(named: "style") { + let styles = parseStyle(style: elementStyle) + if !styles.isEmpty, let colorString = styles["color"] { + attributeValue = UIColor(hexString: colorString) + } + } + } + for (key, formatter) in elementToFormattersMap { if node.isNodeType(key) { if let standardValueFormatter = formatter as? StandardAttributeFormatter, @@ -240,6 +242,22 @@ class HMTLNodeToNSAttributedString: SafeConverter { .h3: HeaderFormatter(headerLevel: .h3), .h4: HeaderFormatter(headerLevel: .h4), .h5: HeaderFormatter(headerLevel: .h5), - .h6: HeaderFormatter(headerLevel: .h6) + .h6: HeaderFormatter(headerLevel: .h6), + .span: ColorFormatter() ] + + func parseStyle(style: String) -> [String: String] { + var stylesDictionary = [String: String]() + let styleAttributes = style.components(separatedBy: ";") + for sytleAttribute in styleAttributes { + let keyValue = sytleAttribute.components(separatedBy: ":") + guard keyValue.count == 2, + let key = keyValue.first?.trimmingCharacters(in: CharacterSet.whitespaces), + let value = keyValue.last?.trimmingCharacters(in: CharacterSet.whitespaces) else { + continue + } + stylesDictionary[key] = value + } + return stylesDictionary + } } diff --git a/Aztec/Classes/Extensions/NSAttributedString+Attachments.swift b/Aztec/Classes/Extensions/NSAttributedString+Attachments.swift index 9db800bec..e6e82a5ba 100644 --- a/Aztec/Classes/Extensions/NSAttributedString+Attachments.swift +++ b/Aztec/Classes/Extensions/NSAttributedString+Attachments.swift @@ -6,6 +6,27 @@ import UIKit // extension NSAttributedString { + /// Indicates the Attributed String Length of a single TextAttachment + /// + static let lengthOfTextAttachment = NSAttributedString(attachment: NSTextAttachment()).length + + + /// String containing the NSTextAttachment Character + /// + static let textAttachmentString = String(UnicodeScalar(NSAttachmentCharacter)!) + + + + /// Helper Initializer: returns an Attributed String, with the specified attachment, styled with a given + /// collection of attributes. + /// + convenience init(attachment: NSTextAttachment, attributes: [String: Any]) { + var attributesWithAttachment = attributes + attributesWithAttachment[NSAttachmentAttributeName] = attachment + + self.init(string: NSAttributedString.textAttachmentString, attributes: attributesWithAttachment) + } + /// Loads any NSTextAttachment's lazy file reference, into a UIImage instance, in memory. /// func loadLazyAttachments() { diff --git a/Aztec/Classes/Extensions/String+RangeConversion.swift b/Aztec/Classes/Extensions/String+RangeConversion.swift index f9e1cd5b1..508f36066 100644 --- a/Aztec/Classes/Extensions/String+RangeConversion.swift +++ b/Aztec/Classes/Extensions/String+RangeConversion.swift @@ -33,7 +33,7 @@ extension String } func location(after: Int) -> Int? { - guard let currentIndex = indexFromLocation(after) else { + guard let currentIndex = indexFromLocation(after), currentIndex != endIndex else { return nil } let afterIndex = index(after: currentIndex) @@ -42,9 +42,10 @@ extension String } func location(before: Int) -> Int? { - guard let currentIndex = indexFromLocation(before) else { + guard let currentIndex = indexFromLocation(before), currentIndex != startIndex else { return nil } + let beforeIndex = index(before: currentIndex) let before16 = beforeIndex.samePosition(in: utf16) return utf16.distance(from: utf16.startIndex, to: before16) diff --git a/Aztec/Classes/Extensions/UIColor+Parsers.swift b/Aztec/Classes/Extensions/UIColor+Parsers.swift new file mode 100644 index 000000000..9b7ea4709 --- /dev/null +++ b/Aztec/Classes/Extensions/UIColor+Parsers.swift @@ -0,0 +1,29 @@ +import UIKit + +extension UIColor { + + /// Creates a color based on a hexString. If the string is not a valid hexColor it return nil + /// Example of colors: #FF0000, #00FF0000 + /// + convenience init?(hexString: String) { + + let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int = UInt32() + if !Scanner(string: hex).scanHexInt32(&int) { + return nil + } + let a, r, g, b: UInt32 + switch hex.characters.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + return nil + } + self.init(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255) + } + +} diff --git a/Aztec/Classes/Formatters/AttributeFormatter.swift b/Aztec/Classes/Formatters/AttributeFormatter.swift index 07d04ac07..0cd0abb76 100644 --- a/Aztec/Classes/Formatters/AttributeFormatter.swift +++ b/Aztec/Classes/Formatters/AttributeFormatter.swift @@ -21,7 +21,9 @@ protocol AttributeFormatter { /// - text: Text that should be formatted. /// - range: Segment of text which format should be toggled. /// - @discardableResult func toggle(in text: NSMutableAttributedString, at range: NSRange) -> NSRange? + /// - Returns: the full range where the toggle was applied + /// + @discardableResult func toggle(in text: NSMutableAttributedString, at range: NSRange) -> NSRange /// Apply or removes formatter attributes to the provided attribute dictionary and returns it. /// @@ -49,11 +51,11 @@ protocol AttributeFormatter { /// Applies the Formatter's Attributes into a given string, at the specified range. /// - func applyAttributes(to string: NSMutableAttributedString, at range: NSRange) + @discardableResult func applyAttributes(to string: NSMutableAttributedString, at range: NSRange) -> NSRange /// Removes the Formatter's Attributes from a given string, at the specified range. /// - func removeAttributes(from string: NSMutableAttributedString, at range: NSRange) + @discardableResult func removeAttributes(from string: NSMutableAttributedString, at range: NSRange) -> NSRange /// Checks if the attribute is present in a dictionary of attributes. /// @@ -111,13 +113,15 @@ extension AttributeFormatter { /// Applies the Formatter's Attributes into a given string, at the specified range. /// - func applyAttributes(to text: NSMutableAttributedString, at range: NSRange) { + /// - Returns: the full range where the attributes where applied + /// + @discardableResult func applyAttributes(to text: NSMutableAttributedString, at range: NSRange) -> NSRange { var rangeToApply = applicationRange(for: range, in: text) if worksInEmptyRange() && ( rangeToApply.length == 0 || text.length == 0) { let placeholder = placeholderForEmptyLine(using: placeholderAttributes) text.insert(placeholder, at: rangeToApply.location) - rangeToApply = NSMakeRange(text.length - 1, 1) + rangeToApply = NSMakeRange(rangeToApply.location, placeholder.length) } text.enumerateAttributes(in: rangeToApply, options: []) { (attributes, range, stop) in @@ -125,11 +129,15 @@ extension AttributeFormatter { let attributes = apply(to: currentAttributes) text.addAttributes(attributes, range: range) } + + return rangeToApply } /// Removes the Formatter's Attributes from a given string, at the specified range. /// - func removeAttributes(from text: NSMutableAttributedString, at range: NSRange) { + /// - Returns: the full range where the attributes where removed + /// + @discardableResult func removeAttributes(from text: NSMutableAttributedString, at range: NSRange) -> NSRange { let rangeToApply = applicationRange(for: range, in: text) text.enumerateAttributes(in: rangeToApply, options: []) { (attributes, range, stop) in let currentAttributes = text.attributes(at: range.location, effectiveRange: nil) @@ -144,22 +152,21 @@ extension AttributeFormatter { text.addAttributes(attributes, range: range) } + return rangeToApply } /// Toggles the Attribute Format, into a given string, at the specified range. /// @discardableResult - func toggle(in text: NSMutableAttributedString, at range: NSRange) -> NSRange? { + func toggle(in text: NSMutableAttributedString, at range: NSRange) -> NSRange { //We decide if we need to apply or not the attribute based on the value on the initial position of the range let shouldApply = shouldApplyAttributes(to: text, at: range) if shouldApply { - applyAttributes(to: text, at: range) + return applyAttributes(to: text, at: range) } else { - removeAttributes(from: text, at: range) - } - - return nil + return removeAttributes(from: text, at: range) + } } } diff --git a/Aztec/Classes/Formatters/StandardAttributeFormatter.swift b/Aztec/Classes/Formatters/StandardAttributeFormatter.swift index 8605bb0ae..ae6a404d1 100644 --- a/Aztec/Classes/Formatters/StandardAttributeFormatter.swift +++ b/Aztec/Classes/Formatters/StandardAttributeFormatter.swift @@ -70,3 +70,9 @@ class HRFormatter: StandardAttributeFormatter { } } +class ColorFormatter: StandardAttributeFormatter { + init(color: UIColor = .black) { + super.init(elementType: .span, attributeKey: NSForegroundColorAttributeName, attributeValue: color) + } +} + diff --git a/Aztec/Classes/GUI/FormatBar/FormattingIdentifier.swift b/Aztec/Classes/GUI/FormatBar/FormattingIdentifier.swift index 52599a180..160c29ea2 100644 --- a/Aztec/Classes/GUI/FormatBar/FormattingIdentifier.swift +++ b/Aztec/Classes/GUI/FormatBar/FormattingIdentifier.swift @@ -11,6 +11,7 @@ public enum FormattingIdentifier: String { case blockquote = "blockquote" case link = "link" case media = "media" + case more = "more" case sourcecode = "sourcecode" case header = "header" case header1 = "header1" diff --git a/Aztec/Classes/Libxml2/DOM/Data/CommentNode.swift b/Aztec/Classes/Libxml2/DOM/Data/CommentNode.swift index c7d79c5a1..4c476fb8a 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/CommentNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/CommentNode.swift @@ -35,5 +35,13 @@ extension Libxml2 { override func text() -> String { return String(.newline) } + + override func deleteCharacters(inRange range: NSRange) { + guard range.location == 0 && range.length == length() else { + return + } + + removeFromParent() + } } } diff --git a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift index 96b5dbe5e..373f0eec3 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift @@ -1447,6 +1447,25 @@ extension Libxml2 { } } + func unwrapChildren(first amount: Int) { + assert(children.count >= amount) + + guard let parent = parent else { + assertionFailure("Cannot execute this method if a parent isn't set.") + return + } + + let myIndex = parent.indexOf(childNode: self) + + for _ in 0...(amount - 1) { + parent.insert(children[0], at: myIndex) + } + + if children.count == 0 { + removeFromParent() + } + } + /// Unwraps the receiver's children from the receiver. /// @discardableResult diff --git a/Aztec/Classes/Libxml2/DOM/Data/StandardElementType.swift b/Aztec/Classes/Libxml2/DOM/Data/StandardElementType.swift index db16e2f16..2da1e5a9a 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/StandardElementType.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/StandardElementType.swift @@ -35,6 +35,7 @@ extension Libxml2 { case p = "p" case pre = "pre" case s = "s" + case span = "span" case strike = "strike" case strong = "strong" case table = "table" diff --git a/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift b/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift index 02d824b51..14bf35acc 100644 --- a/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift +++ b/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift @@ -324,7 +324,19 @@ extension Libxml2 { if let rightElement = rightSibling as? ElementNode, rightElement.isBlockLevelElement() { - finalRightNodes = rightElement.unwrapChildren() + if rightElement.children.count > 0, + let rightChildElement = rightElement.children[0] as? ElementNode, + rightChildElement.isBlockLevelElement() { + + rightElement.unwrapChildren(first: 1) + + // This case was designed for lists. They need to unwrap the first list element + // from both the ul / ol and the li elements. + // + finalRightNodes = rightChildElement.unwrapChildren() + } else { + finalRightNodes = rightElement.unwrapChildren() + } } else { finalRightNodes = [rightSibling] } diff --git a/Aztec/Classes/Libxml2/DOMString.swift b/Aztec/Classes/Libxml2/DOMString.swift index 0ee874e2d..844a50358 100644 --- a/Aztec/Classes/Libxml2/DOMString.swift +++ b/Aztec/Classes/Libxml2/DOMString.swift @@ -113,9 +113,10 @@ extension Libxml2 { } catch { fatalError("Could not convert the HTML.") } - + domQueue.sync { self.rootNode = output.rootNode + self.domEditor = DOMEditor(with: output.rootNode) } return output.attributedString @@ -472,19 +473,19 @@ extension Libxml2 { // MARK: - Images - /// Applies an image to the specified range + /// Replaces the specified range with a given image. /// /// - Parameters: - /// - imageURL: the URL for the img src attribute /// - range: the range to insert the image + /// - imageURL: the URL for the img src attribute /// - func insertImage(imageURL: URL, replacing range:NSRange) { + func replace(_ range: NSRange, with imageURL: URL) { performAsyncUndoable { [weak self] in - self?.insertImageSynchronously(imageURL: imageURL, replacing: range) + self?.replaceSynchronously(range, with: imageURL) } } - private func insertImageSynchronously(imageURL: URL, replacing range: NSRange) { + private func replaceSynchronously(_ range: NSRange, with imageURL: URL) { let imageURLString = imageURL.absoluteString let attributes = [Libxml2.StringAttribute(name:"src", value: imageURLString)] @@ -493,23 +494,42 @@ extension Libxml2 { rootNode.replaceCharacters(in: range, with: descriptor) } - /// Applies horizontal ruler to the specified range. + /// Replaces the specified range with a Horizontal Ruler Style. /// /// - Parameters: /// - range: the range to apply the style to. /// - func insertHorizontalRuler(at range: NSRange) { + func replaceWithHorizontalRuler(_ range: NSRange) { performAsyncUndoable { [weak self] in - self?.insertHorizontalRulerSynchronously(at: range) + self?.replaceSynchronouslyWithHorizontalRulerStyle(range) } } - private func insertHorizontalRulerSynchronously(at range: NSRange) { + private func replaceSynchronouslyWithHorizontalRulerStyle(_ range: NSRange) { let descriptor = ElementNodeDescriptor(elementType: .hr) rootNode.replaceCharacters(in: range, with: descriptor) } + /// Replaces the specified range with a Comment. + /// + /// - Parameters: + /// - range: the range to apply the style to. + /// - comment: the comment to be stored. + /// + func replace(_ range: NSRange, with comment: String) { + performAsyncUndoable { [weak self] in + self?.replaceSynchronously(range, with: comment) + } + } + + private func replaceSynchronously(_ range: NSRange, with comment: String) { + let descriptor = CommentNodeDescriptor(comment: comment) + + rootNode.replaceCharacters(in: range, with: descriptor) + } + + // MARK: - Styles to HTML elements /// Applies a standard HTML element to the specified range. diff --git a/Aztec/Classes/Libxml2/Descriptors/CommentNodeDescriptor.swift b/Aztec/Classes/Libxml2/Descriptors/CommentNodeDescriptor.swift index f25d6742a..6fbbb592b 100644 --- a/Aztec/Classes/Libxml2/Descriptors/CommentNodeDescriptor.swift +++ b/Aztec/Classes/Libxml2/Descriptors/CommentNodeDescriptor.swift @@ -8,6 +8,7 @@ extension Libxml2 { /// - Creating it. /// class CommentNodeDescriptor: NodeDescriptor { + let nodeName = "comment" let comment: String // MARK: - CustomReflectable @@ -18,9 +19,9 @@ extension Libxml2 { } } - init(name: String, comment: String) { + init(comment: String) { self.comment = comment - super.init(name: name) + super.init(name: nodeName) } } } diff --git a/Aztec/Classes/TextKit/CommentAttachment.swift b/Aztec/Classes/TextKit/CommentAttachment.swift new file mode 100644 index 000000000..c53eebc02 --- /dev/null +++ b/Aztec/Classes/TextKit/CommentAttachment.swift @@ -0,0 +1,72 @@ +import Foundation +import UIKit + + +/// Comment Attachment's Delegate Helpers +/// +protocol CommentAttachmentDelegate: class { + + /// Returns the Bounds that should be used to render the current attachment. + /// + /// - Parameters: + /// - commentAttachment: The Comment to be rendered + /// - fragment: Current Line Fragment Bounds + /// + /// - Returns: CGRect specifiying the Attachment Bounds. + /// + func commentAttachment(_ commentAttachment: CommentAttachment, boundsForLineFragment fragment: CGRect) -> CGRect + + /// Returns the Image Representation for a given attachment. + /// + /// - Parameters: + /// - commentAttachment: The Comment to be rendered + /// - size: The Canvas Size + /// + /// - Returns: Optional UIImage instance, representing a given comment. + /// + func commentAttachment(_ commentAttachment: CommentAttachment, imageForSize size: CGSize) -> UIImage? +} + + +/// Comment Attachments: Represents an HTML Comment +/// +open class CommentAttachment: NSTextAttachment { + + /// Internal Cached Image + /// + fileprivate var glyphImage: UIImage? + + /// Delegate + /// + weak var delegate: CommentAttachmentDelegate? + + /// A message to display overlaid on top of the image + /// + open var text: String = "" { + didSet { + glyphImage = nil + } + } + + + // MARK: - NSTextAttachmentContainer + + override open func image(forBounds imageBounds: CGRect, textContainer: NSTextContainer?, characterIndex charIndex: Int) -> UIImage? { + if let cachedImage = glyphImage, imageBounds.size.equalTo(cachedImage.size) { + return cachedImage + } + + glyphImage = delegate?.commentAttachment(self, imageForSize: imageBounds.size) + + return glyphImage + } + + override open func attachmentBounds(for textContainer: NSTextContainer?, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int) -> CGRect { + guard let bounds = delegate?.commentAttachment(self, boundsForLineFragment: lineFrag) else { + assertionFailure("Could not determine Comment Attachment Size") + return .zero + } + + return bounds + } +} diff --git a/Aztec/Classes/TextKit/MoreAttachment.swift b/Aztec/Classes/TextKit/MoreAttachment.swift deleted file mode 100644 index ae6e14d2d..000000000 --- a/Aztec/Classes/TextKit/MoreAttachment.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation -import UIKit - -/// Custom text attachment. -/// -open class MoreAttachment: NSTextAttachment -{ - fileprivate var glyphImage: UIImage? - - /// The color to use when drawing progress indicators - /// - open var color: UIColor = UIColor.gray - - /// A message to display overlaid on top of the image - /// - open var label: NSAttributedString = NSAttributedString(string: "MORE") { - willSet { - if newValue != label { - glyphImage = nil - } - } - } - - open var message: String = "" - - // MARK: - NSTextAttachmentContainer - - override open func image(forBounds imageBounds: CGRect, textContainer: NSTextContainer?, characterIndex charIndex: Int) -> UIImage? { - - if let cachedImage = glyphImage, imageBounds.size.equalTo(cachedImage.size) { - return cachedImage - } - - glyphImage = glyph(forBounds: imageBounds) - - return glyphImage - } - - fileprivate func glyph(forBounds bounds: CGRect) -> UIImage? { - - let size = bounds.size - - UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0) - - let colorMessage = NSMutableAttributedString(attributedString: label) - colorMessage.addAttribute(NSForegroundColorAttributeName, value: color, range: label.rangeOfEntireString) - let textRect = colorMessage.boundingRect(with: size, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil) - let textPosition = CGPoint(x: ((size.width - textRect.width) / 2), y: ((size.height - textRect.height) / 2) ) - colorMessage.draw(in: CGRect(origin: textPosition , size: CGSize(width: size.width, height: textRect.size.height))) - - let path = UIBezierPath() - - let dashWidth: CGFloat = 8.0 - let dashes: [ CGFloat ] = [ dashWidth, dashWidth ] - path.setLineDash(dashes, count: dashes.count, phase: 0.0) - path.lineWidth = 2.0 - let centerY = round(size.height / 2.0) - path.move(to: CGPoint(x:0, y: centerY)) - path.addLine(to: CGPoint(x: ((size.width - textRect.width) / 2) - dashWidth, y: centerY)) - - path.move(to: CGPoint(x:((size.width + textRect.width) / 2) + dashWidth, y: centerY)) - path.addLine(to: CGPoint(x: size.width, y: centerY)) - - color.setStroke() - path.stroke() - - let result = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - return result; - } - - override open func attachmentBounds(for textContainer: NSTextContainer?, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int) -> CGRect { - - let padding = textContainer?.lineFragmentPadding ?? 0 - let width = lineFrag.width - padding * 2 - let height:CGFloat = 44.0 - - return CGRect(origin: CGPoint.zero, size: CGSize(width: width, height: height)) - } -} diff --git a/Aztec/Classes/TextKit/TextAttachment.swift b/Aztec/Classes/TextKit/TextAttachment.swift index 05382e6dd..a59354e37 100644 --- a/Aztec/Classes/TextKit/TextAttachment.swift +++ b/Aztec/Classes/TextKit/TextAttachment.swift @@ -1,7 +1,7 @@ import Foundation import UIKit -protocol TextAttachmentImageProvider { +protocol TextAttachmentDelegate: class { func textAttachment( _ textAttachment: TextAttachment, imageForURL url: URL, @@ -127,7 +127,7 @@ open class TextAttachment: NSTextAttachment fileprivate var glyphImage: UIImage? - var imageProvider: TextAttachmentImageProvider? + weak var delegate: TextAttachmentDelegate? var isFetchingImage: Bool = false @@ -343,7 +343,7 @@ open class TextAttachment: NSTextAttachment func updateImage(inTextContainer textContainer: NSTextContainer? = nil) { - guard let imageProvider = imageProvider else { + guard let delegate = delegate else { assertionFailure("This class doesn't really support not having an updater set.") return } @@ -354,9 +354,7 @@ open class TextAttachment: NSTextAttachment isFetchingImage = true - let image = imageProvider.textAttachment(self, - imageForURL: url, - onSuccess: { [weak self] (image) in + let image = delegate.textAttachment(self, imageForURL: url, onSuccess: { [weak self] image in guard let strongSelf = self else { return } @@ -364,7 +362,7 @@ open class TextAttachment: NSTextAttachment strongSelf.isFetchingImage = false strongSelf.image = image strongSelf.invalidateLayout(inTextContainer: textContainer) - }, onFailure: { [weak self]() in + }, onFailure: { [weak self] _ in guard let strongSelf = self else { return diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index 32db0eaa1..fb32bd749 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -1,19 +1,21 @@ import Foundation import UIKit + /// Implemented by a class taking care of handling attachments for the storage. /// protocol TextStorageAttachmentsDelegate { /// Provides images for attachments that are part of the storage /// - /// - parameter storage: The storage that is requesting the image. - /// - parameter attachment: The attachment that is requesting the image. - /// - parameter url: url for the image. - /// - parameter success: a callback block to be invoked with the image fetched from the url. - /// - parameter failure: a callback block to be invoked when an error occurs when fetching the image. + /// - Parameters: + /// - storage: The storage that is requesting the image. + /// - attachment: The attachment that is requesting the image. + /// - url: url for the image. + /// - success: Callback block to be invoked with the image fetched from the url. + /// - failure: Callback block to be invoked when an error occurs when fetching the image. /// - /// - returns: returns a temporary UIImage to be used while the request is happening + /// - Returns: returns a temporary UIImage to be used while the request is happening /// func storage( _ storage: TextStorage, @@ -28,8 +30,8 @@ protocol TextStorageAttachmentsDelegate { /// delegate can specify an URL where that image is available. /// /// - Parameters: - /// - storage: The storage that is requesting the image. - /// - image: The image that was added to the storage. + /// - storage: The storage that is requesting the image. + /// - image: The image that was added to the storage. /// /// - Returns: the requested `NSURL` where the image is stored. /// @@ -40,9 +42,33 @@ protocol TextStorageAttachmentsDelegate { /// - Parameters: /// - textView: The textView where the attachment was removed. /// - attachmentID: The attachment identifier of the media removed. - func storage(_ storage: TextStorage, deletedAttachmentWithID attachmentID: String); + /// + func storage(_ storage: TextStorage, deletedAttachmentWithID attachmentID: String) + + /// Provides the Bounds required to represent a given attachment, within a specified line fragment. + /// + /// - Parameters: + /// - storage: The storage that is requesting the bounds. + /// - attachment: CommentAttachment about to be rendered. + /// - lineFragment: Line Fragment in which the glyph would be rendered. + /// + /// - Returns: Rect specifying the Bounds for the comment attachment + /// + func storage(_ storage: TextStorage, boundsForComment attachment: CommentAttachment, with lineFragment: CGRect) -> CGRect + + /// Provides the (Optional) Image Representation of the specified size, for a given Attachment. + /// + /// - Parameters: + /// - storage: The storage that is requesting the bounds. + /// - attachment: CommentAttachment about to be rendered. + /// - size: Expected Image Size + /// + /// - Returns: (Optional) UIImage representation of the Comment Attachment. + /// + func storage(_ storage: TextStorage, imageForComment attachment: CommentAttachment, with size: CGSize) -> UIImage? } + /// Custom NSTextStorage /// open class TextStorage: NSTextStorage { @@ -90,7 +116,7 @@ open class TextStorage: NSTextStorage { // MARK: - Attachments - var attachmentsDelegate: TextStorageAttachmentsDelegate? + var attachmentsDelegate: TextStorageAttachmentsDelegate! open func TextAttachments() -> [TextAttachment] { let range = NSMakeRange(0, length) @@ -171,45 +197,44 @@ open class TextStorage: NSTextStorage { /// - Returns: the preprocessed string. /// fileprivate func preprocessAttachmentsForInsertion(_ attributedString: NSAttributedString) -> NSAttributedString { - + assert(attachmentsDelegate != nil) + let fullRange = NSRange(location: 0, length: attributedString.length) let finalString = NSMutableAttributedString(attributedString: attributedString) attributedString.enumerateAttribute(NSAttachmentAttributeName, in: fullRange, options: []) { (object, range, stop) in - - guard let object = object, !(object is LineAttachment) else { - return - } - - guard let attachmentsDelegate = attachmentsDelegate else { - assertionFailure("This class can't really handle not having an image provider set.") + guard let object = object else { return } - guard let attachment = object as? NSTextAttachment else { + guard let textAttachment = object as? NSTextAttachment else { assertionFailure("We expected a text attachment object.") return } - - guard let image = attachment.image else { - // We only suppot image attachments for now. All other attachment types are - // stripped for safety. - // - finalString.removeAttribute(NSAttachmentAttributeName, range: range) - return - } - - if let textAttachment = attachment as? TextAttachment { - textAttachment.imageProvider = self - return + + switch textAttachment { + case _ as LineAttachment: + break + case let attachment as CommentAttachment: + attachment.delegate = self + case let attachment as TextAttachment: + attachment.delegate = self + default: + guard let image = textAttachment.image else { + // We only suppot image attachments for now. All other attachment types are + /// stripped for safety. + // + finalString.removeAttribute(NSAttachmentAttributeName, range: range) + return + } + + let replacementAttachment = TextAttachment() + replacementAttachment.delegate = self + replacementAttachment.image = image + replacementAttachment.url = attachmentsDelegate.storage(self, urlForAttachment: replacementAttachment) + + finalString.addAttribute(NSAttachmentAttributeName, value: replacementAttachment, range: range) } - - let replacementAttachment = TextAttachment() - replacementAttachment.imageProvider = self - replacementAttachment.image = image - replacementAttachment.url = attachmentsDelegate.storage(self, urlForAttachment: replacementAttachment) - - finalString.addAttribute(NSAttachmentAttributeName, value: replacementAttachment, range: range) } return finalString @@ -217,7 +242,7 @@ open class TextStorage: NSTextStorage { fileprivate func detectAttachmentRemoved(in range:NSRange) { textStore.enumerateAttachmentsOfType(TextAttachment.self, range: range) { (attachment, range, stop) in - self.attachmentsDelegate?.storage(self, deletedAttachmentWithID: attachment.identifier) + self.attachmentsDelegate.storage(self, deletedAttachmentWithID: attachment.identifier) } } @@ -263,7 +288,7 @@ open class TextStorage: NSTextStorage { dom.deleteBlockSeparator(at: targetDomRange.location) } - applyStylesToDom(from: preprocessedString, startingAt: range.location) + applyStylesToDom(from: domString, startingAt: range.location) } detectAttachmentRemoved(in: range) @@ -339,6 +364,9 @@ open class TextStorage: NSTextStorage { /// - targetValue: the new value of the attribute /// private func processAttributesDifference(in domRange: NSRange, key: String, sourceValue: Any?, targetValue: Any?) { + let isLineAttachment = sourceValue is LineAttachment || targetValue is LineAttachment + let isCommentAttachment = sourceValue is CommentAttachment || targetValue is CommentAttachment + switch(key) { case NSFontAttributeName: let sourceFont = sourceValue as? UIFont @@ -355,14 +383,17 @@ open class TextStorage: NSTextStorage { let targetStyle = targetValue as? NSNumber processUnderlineDifferences(in: domRange, betweenOriginal: sourceStyle, andNew: targetStyle) - case NSAttachmentAttributeName: - if sourceValue is LineAttachment || targetValue is LineAttachment { - let sourceAttachment = sourceValue as? LineAttachment - let targetAttachment = targetValue as? LineAttachment + case NSAttachmentAttributeName where isLineAttachment: + let sourceAttachment = sourceValue as? LineAttachment + let targetAttachment = targetValue as? LineAttachment - processLineAttachmentDifferences(in: domRange, betweenOriginal: sourceAttachment, andNew: targetAttachment) - return - } + processLineAttachmentDifferences(in: domRange, betweenOriginal: sourceAttachment, andNew: targetAttachment) + case NSAttachmentAttributeName where isCommentAttachment: + let sourceAttachment = sourceValue as? CommentAttachment + let targetAttachment = targetValue as? CommentAttachment + + processCommentAttachmentDifferences(in: domRange, betweenOriginal: sourceAttachment, andNew: targetAttachment) + case NSAttachmentAttributeName: let sourceAttachment = sourceValue as? TextAttachment let targetAttachment = targetValue as? TextAttachment @@ -439,7 +470,7 @@ open class TextStorage: NSTextStorage { return } - dom.insertImage(imageURL: urlToAdd, replacing: range) + dom.replace(range, with: urlToAdd) } else if removeImageUrl { dom.removeImage(spanning: range) } @@ -447,16 +478,18 @@ open class TextStorage: NSTextStorage { private func processLineAttachmentDifferences(in range: NSRange, betweenOriginal original: LineAttachment?, andNew new: LineAttachment?) { - let add = original == nil && new != nil - let remove = original != nil && new == nil + dom.replaceWithHorizontalRuler(range) + } - if add { - dom.insertHorizontalRuler(at: range) - } else if remove { - dom.remove(element: .hr, at: range) + private func processCommentAttachmentDifferences(in range: NSRange, betweenOriginal original: CommentAttachment?, andNew new: CommentAttachment?) { + guard let newAttachment = new else { + return } + + dom.replace(range, with: newAttachment.text) } + /// Processes differences in the italic trait of two font objects, and applies them to the DOM /// in the specified range. /// @@ -600,7 +633,7 @@ open class TextStorage: NSTextStorage { private func canAppendToNodeRepresentedByCharacter(atIndex index: Int) -> Bool { return !hasNewLine(atIndex: index) && !hasHorizontalLine(atIndex: index) - && !hasMoreMarker(atIndex: index) + && !hasCommentMarker(atIndex: index) && !hasVisualOnlyElement(atIndex: index) } @@ -622,9 +655,9 @@ open class TextStorage: NSTextStorage { return true } - private func hasMoreMarker(atIndex index: Int) -> Bool { + private func hasCommentMarker(atIndex index: Int) -> Bool { guard let attachment = attribute(NSAttachmentAttributeName, at: index, effectiveRange: nil), - attachment is MoreAttachment else { + attachment is CommentAttachment else { return false } @@ -656,7 +689,7 @@ open class TextStorage: NSTextStorage { } // MARK: - Styles: Toggling - @discardableResult func toggle(formatter: AttributeFormatter, at range: NSRange) -> NSRange? { + @discardableResult func toggle(formatter: AttributeFormatter, at range: NSRange) -> NSRange { let applicationRange = formatter.applicationRange(for: range, in: self) if applicationRange.length == 0, !formatter.worksInEmptyRange() { return applicationRange @@ -675,7 +708,7 @@ open class TextStorage: NSTextStorage { /// func insertImage(sourceURL url: URL, atPosition position:Int, placeHolderImage: UIImage, identifier: String = UUID().uuidString) -> TextAttachment { let attachment = TextAttachment(identifier: identifier) - attachment.imageProvider = self + attachment.delegate = self attachment.url = url attachment.image = placeHolderImage @@ -691,7 +724,7 @@ open class TextStorage: NSTextStorage { /// /// - Parameter range: the range where the element will be inserted /// - func insertHorizontalRuler(at range: NSRange) { + func replaceRangeWithHorizontalRuler(_ range: NSRange) { let line = LineAttachment() let attachmentString = NSAttributedString(attachment: line) @@ -771,6 +804,19 @@ open class TextStorage: NSTextStorage { } } + /// Inserts the Comment Attachment at the specified position + /// + @discardableResult + open func replaceRangeWithCommentAttachment(_ range: NSRange, text: String, attributes: [String: Any]) -> CommentAttachment { + let attachment = CommentAttachment() + attachment.text = text + + let stringWithAttachment = NSAttributedString(attachment: attachment, attributes: attributes) + replaceCharacters(in: range, with: stringWithAttachment) + + return attachment + } + // MARK: - Toggle Attributes @@ -823,14 +869,21 @@ open class TextStorage: NSTextStorage { let originalLength = textStore.length textStore = NSMutableAttributedString(attributedString: attributedString) - textStore.enumerateAttachmentsOfType(TextAttachment.self) { [weak self] (attachment, range, stop) in - attachment.imageProvider = self + textStore.enumerateAttachmentsOfType(TextAttachment.self) { [weak self] (attachment, _, _) in + attachment.delegate = self + } + textStore.enumerateAttachmentsOfType(CommentAttachment.self) { [weak self] (attachment, _, _) in + attachment.delegate = self } + edited([.editedAttributes, .editedCharacters], range: NSRange(location: 0, length: originalLength), changeInLength: textStore.length - originalLength) } } -extension TextStorage: TextAttachmentImageProvider { + +// MARK: - TextStorage: TextAttachmentDelegate Methods +// +extension TextStorage: TextAttachmentDelegate { func textAttachment( _ textAttachment: TextAttachment, @@ -838,11 +891,24 @@ extension TextStorage: TextAttachmentImageProvider { onSuccess success: @escaping (UIImage) -> (), onFailure failure: @escaping () -> ()) -> UIImage { - guard let attachmentsDelegate = attachmentsDelegate else { - fatalError("This class doesn't really support not having an attachments delegate set.") - } - + assert(attachmentsDelegate != nil) return attachmentsDelegate.storage(self, attachment: textAttachment, imageForURL: url, onSuccess: success, onFailure: failure) } } + + +// MARK: - TextStorage: CommentAttachmentDelegate Methods +// +extension TextStorage: CommentAttachmentDelegate { + + func commentAttachment(_ commentAttachment: CommentAttachment, imageForSize size: CGSize) -> UIImage? { + assert(attachmentsDelegate != nil) + return attachmentsDelegate.storage(self, imageForComment: commentAttachment, with: size) + } + + func commentAttachment(_ commentAttachment: CommentAttachment, boundsForLineFragment fragment: CGRect) -> CGRect { + assert(attachmentsDelegate != nil) + return attachmentsDelegate.storage(self, boundsForComment: commentAttachment, with: fragment) + } +} diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 02cd42640..350b7e2a5 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -3,6 +3,9 @@ import UIKit import Foundation import Gridicons + +// MARK: - TextViewMediaDelegate +// public protocol TextViewMediaDelegate: class { /// This method requests from the delegate the image at the specified URL. @@ -62,6 +65,37 @@ public protocol TextViewMediaDelegate: class { func textView(_ textView: TextView, deselectedAttachment attachment: TextAttachment, atPosition position: CGPoint) } + +// MARK: - TextViewCommentsDelegate +// +public protocol TextViewCommentsDelegate: class { + + /// Provides the Bounds required to represent a given attachment, within a specified line fragment. + /// + /// - Parameters: + /// - textView: The textView that is requesting the bounds. + /// - attachment: CommentAttachment about to be rendered. + /// - lineFragment: Line Fragment in which the glyph would be rendered. + /// + /// - Returns: Rect specifying the Bounds for the comment attachment + /// + func textView(_ textView: TextView, boundsForComment attachment: CommentAttachment, with lineFragment: CGRect) -> CGRect + + /// Provides the (Optional) Image Representation of the specified size, for a given Attachment. + /// + /// - Parameters: + /// - textView: The textView that is requesting the bounds. + /// - attachment: CommentAttachment about to be rendered. + /// - size: Expected Image Size + /// + /// - Returns: (Optional) UIImage representation of the Comment Attachment. + /// + func textView(_ textView: TextView, imageForComment attachment: CommentAttachment, with size: CGSize) -> UIImage? +} + + +// MARK: - TextViewFormattingDelegate +// public protocol TextViewFormattingDelegate: class { /// Called a text view command toggled a style. @@ -71,6 +105,9 @@ public protocol TextViewFormattingDelegate: class { func textViewCommandToggledAStyle() } + +// MARK: - TextView +// open class TextView: UITextView { typealias ElementNode = Libxml2.ElementNode @@ -83,6 +120,10 @@ open class TextView: UITextView { /// open weak var mediaDelegate: TextViewMediaDelegate? + // MARK: - Properties: Comment Attachments + + open weak var commentsDelegate: TextViewCommentsDelegate? + // MARK: - Properties: Formatting open weak var formattingDelegate: TextViewFormattingDelegate? @@ -426,9 +467,8 @@ open class TextView: UITextView { // MARK: - Formatting func toggle(formatter: AttributeFormatter, atRange range: NSRange) { - let newSelectedRange = storage.toggle(formatter: formatter, at: range) - selectedRange = newSelectedRange ?? selectedRange - if selectedRange.length == 0 { + let applicationRange = storage.toggle(formatter: formatter, at: range) + if applicationRange.length == 0 { typingAttributes = formatter.toggle(in: typingAttributes) } else { // NOTE: We are making sure that the selectedRange location is inside the string @@ -529,8 +569,8 @@ open class TextView: UITextView { /// /// - Parameter range: the range where the ruler will be inserted /// - open func replaceWithHorizontalRuler(at range: NSRange) { - storage.insertHorizontalRuler(at: range) + open func replaceRangeWithHorizontalRuler(_ range: NSRange) { + storage.replaceRangeWithHorizontalRuler(range) let length = NSAttributedString(attachment:NSTextAttachment()).length textStorage.addAttributes(typingAttributes, range: NSMakeRange(range.location, length)) selectedRange = NSMakeRange(range.location + length, 0) @@ -804,7 +844,7 @@ open class TextView: UITextView { /// open func insertImage(sourceURL url: URL, atPosition position: Int, placeHolderImage: UIImage?, identifier: String = UUID().uuidString) -> TextAttachment { let attachment = storage.insertImage(sourceURL: url, atPosition: position, placeHolderImage: placeHolderImage ?? defaultMissingImage, identifier: identifier) - let length = NSAttributedString(attachment:NSTextAttachment()).length + let length = NSAttributedString.lengthOfTextAttachment textStorage.addAttributes(typingAttributes, range: NSMakeRange(position, length)) selectedRange = NSMakeRange(position+length, 0) delegate?.textViewDidChange?(self) @@ -1000,7 +1040,28 @@ open class TextView: UITextView { /// open func refreshLayoutFor(attachment: TextAttachment) { layoutManager.invalidateLayoutForAttachment(attachment) - } + } + + + // MARK: - More + + /// Inserts an HTML Comment at the specified position. + /// + /// - Parameters: + /// - range: The character range that must be replaced with a Comment Attachment. + /// - text: The Comment Attachment's Text. + /// + /// - Returns: the attachment object that can be used for further calls + /// + @discardableResult + open func replaceRangeWithCommentAttachment(_ range: NSRange, text: String) -> CommentAttachment { + let attachment = storage.replaceRangeWithCommentAttachment(range, text: text, attributes: typingAttributes) + + selectedRange = NSMakeRange(range.location + NSAttributedString.lengthOfTextAttachment, 0) + delegate?.textViewDidChange?(self) + + return attachment + } } @@ -1028,7 +1089,6 @@ extension TextView: TextStorageAttachmentsDelegate { } func storage(_ storage: TextStorage, urlForAttachment attachment: TextAttachment) -> URL { - guard let mediaDelegate = mediaDelegate else { fatalError("This class requires a media delegate to be set.") } @@ -1039,6 +1099,22 @@ extension TextView: TextStorageAttachmentsDelegate { func storage(_ storage: TextStorage, deletedAttachmentWithID attachmentID: String) { mediaDelegate?.textView(self, deletedAttachmentWithID: attachmentID) } + + func storage(_ storage: TextStorage, imageForComment attachment: CommentAttachment, with size: CGSize) -> UIImage? { + guard let commentsDelegate = commentsDelegate else { + fatalError("This class requires a comments delegate to be set.") + } + + return commentsDelegate.textView(self, imageForComment: attachment, with: size) + } + + func storage(_ storage: TextStorage, boundsForComment attachment: CommentAttachment, with lineFragment: CGRect) -> CGRect { + guard let commentsDelegate = commentsDelegate else { + fatalError("This class requires a comments delegate to be set.") + } + + return commentsDelegate.textView(self, boundsForComment: attachment, with: lineFragment) + } } // MARK: - UIGestureRecognizerDelegate diff --git a/AztecTests/Extensions/UIColorHexParserTests.swift b/AztecTests/Extensions/UIColorHexParserTests.swift new file mode 100644 index 000000000..c8de37b83 --- /dev/null +++ b/AztecTests/Extensions/UIColorHexParserTests.swift @@ -0,0 +1,61 @@ +import XCTest +@testable import Aztec + +class UIColorHexParserTests: XCTestCase { + + override func setUp() { + super.setUp() + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + super.tearDown() + } + + func testParseOf24bitsHexColors() { + var color = UIColor(hexString: "#FF0000") + + XCTAssertEqual(color, UIColor.red) + + color = UIColor(hexString: "#00FF00") + + XCTAssertEqual(color, UIColor.green) + + color = UIColor(hexString: "#0000FF") + + XCTAssertEqual(color, UIColor.blue) + + color = UIColor(hexString: "#FFFFFF") + + XCTAssertEqual(color, UIColor.init(colorLiteralRed: 1, green: 1, blue: 1, alpha: 1)) + } + + func testParseOf32bitsHexColors() { + var color = UIColor(hexString: "#FFFF0000") + + XCTAssertEqual(color, UIColor.red) + + color = UIColor(hexString: "#FF00FF00") + + XCTAssertEqual(color, UIColor.green) + + color = UIColor(hexString: "#FF0000FF") + + XCTAssertEqual(color, UIColor.blue) + + color = UIColor(hexString: "#FFFFFFFF") + + XCTAssertEqual(color, UIColor.init(colorLiteralRed: 1, green: 1, blue: 1, alpha: 1)) + } + + func testFailingColor() { + var color = UIColor(hexString: "#") + + XCTAssertEqual(color, nil) + + color = UIColor(hexString: "#ZZZZZZ") + + XCTAssertEqual(color, nil) + } +} diff --git a/AztecTests/StringRangeConversionTests.swift b/AztecTests/StringRangeConversionTests.swift index 13ade761c..dd9be98f9 100644 --- a/AztecTests/StringRangeConversionTests.swift +++ b/AztecTests/StringRangeConversionTests.swift @@ -139,5 +139,19 @@ class StringRangeConversionTests: XCTestCase { XCTAssertEqual("Hello ", wordCaptured) } + func testLocationBeforeAtLimits() { + // test at start of string + let string = "" + let nonExistentLocation = string.location(before: 0) + XCTAssertNil(nonExistentLocation) + } + + func testLocationAfterAtLimits() { + // test at start of string + let string = "" + let nonExistentLocation = string.location(after: 0) + XCTAssertNil(nonExistentLocation) + } + } diff --git a/AztecTests/TextStorageTests.swift b/AztecTests/TextStorageTests.swift index cd1942af0..a8658b5a3 100644 --- a/AztecTests/TextStorageTests.swift +++ b/AztecTests/TextStorageTests.swift @@ -22,7 +22,9 @@ class TextStorageTests: XCTestCase let attributes = [ NSFontAttributeName: UIFont.boldSystemFont(ofSize: 10) ] + let mockDelegate = MockAttachmentsDelegate() let storage = TextStorage() + storage.attachmentsDelegate = mockDelegate storage.append(NSAttributedString(string: "foo")) storage.append(NSAttributedString(string: "bar", attributes: attributes)) storage.append(NSAttributedString(string: "baz")) @@ -43,7 +45,9 @@ class TextStorageTests: XCTestCase let attributes = [ NSFontAttributeName: UIFont.boldSystemFont(ofSize: 10) ] + let mockDelegate = MockAttachmentsDelegate() let storage = TextStorage() + storage.attachmentsDelegate = mockDelegate storage.append(NSAttributedString(string: "foo")) storage.append(NSAttributedString(string: "bar", attributes: attributes)) storage.append(NSAttributedString(string: "baz")) @@ -57,7 +61,9 @@ class TextStorageTests: XCTestCase let attributes = [ NSFontAttributeName: UIFont.boldSystemFont(ofSize: 10) ] + let mockDelegate = MockAttachmentsDelegate() let storage = TextStorage() + storage.attachmentsDelegate = mockDelegate storage.append(NSAttributedString(string: "foo")) storage.append(NSAttributedString(string: "bar", attributes: attributes)) storage.append(NSAttributedString(string: "baz")) @@ -81,8 +87,8 @@ class TextStorageTests: XCTestCase } func testDelegateCallbackWhenAttachmentRemoved() { - let storage = TextStorage() let mockDelegate = MockAttachmentsDelegate() + let storage = TextStorage() storage.attachmentsDelegate = mockDelegate let attachment = storage.insertImage(sourceURL: URL(string:"test://")!, atPosition: 0, placeHolderImage: UIImage()) @@ -111,6 +117,14 @@ class TextStorageTests: XCTestCase func storage(_ storage: TextStorage, attachment: TextAttachment, imageForURL url: URL, onSuccess success: @escaping (UIImage) -> (), onFailure failure: @escaping () -> ()) -> UIImage { return UIImage() } + + func storage(_ storage: TextStorage, boundsForComment attachment: CommentAttachment, with lineFragment: CGRect) -> CGRect { + return .zero + } + + func storage(_ storage: TextStorage, imageForComment attachment: CommentAttachment, with size: CGSize) -> UIImage? { + return UIImage() + } } func testRemovalOfAttachment() { @@ -151,7 +165,9 @@ class TextStorageTests: XCTestCase } func testBlockquoteToggle() { + let mockDelegate = MockAttachmentsDelegate() let storage = TextStorage() + storage.attachmentsDelegate = mockDelegate storage.append(NSAttributedString(string: "Apply a blockquote")) let blockquoteFormatter = BlockquoteFormatter() storage.toggle(formatter: blockquoteFormatter, at: storage.rangeOfEntireString) @@ -169,6 +185,9 @@ class TextStorageTests: XCTestCase func testLinkInsert() { let storage = TextStorage() + let mockDelegate = MockAttachmentsDelegate() + storage.attachmentsDelegate = mockDelegate + storage.append(NSAttributedString(string: "Apply a link")) let linkFormatter = LinkFormatter() linkFormatter.attributeValue = URL(string: "www.wordpress.com")! @@ -187,6 +206,9 @@ class TextStorageTests: XCTestCase func testHeaderToggle() { let storage = TextStorage() + let mockDelegate = MockAttachmentsDelegate() + storage.attachmentsDelegate = mockDelegate + storage.append(NSAttributedString(string: "Apply a header")) let formatter = HeaderFormatter(headerLevel: .h1) storage.toggle(formatter: formatter, at: storage.rangeOfEntireString) @@ -275,28 +297,87 @@ class TextStorageTests: XCTestCase /// This test check if the insertion of an horizontal ruler works correctly and the hr tag is inserted /// - func testInsertHorizontalRuler() { + func testReplaceRangeWithHorizontalRuler() { let storage = TextStorage() let mockDelegate = MockAttachmentsDelegate() storage.attachmentsDelegate = mockDelegate - storage.insertHorizontalRuler(at: NSRange.zero) + storage.replaceRangeWithHorizontalRuler(.zero) let html = storage.getHTML() XCTAssertEqual(html, "
") } + /// This test check if the insertion of antwo horizontal ruler works correctly and the hr tag(s) are inserted + /// + func testReplaceRangeWithHorizontalRulerGeneratesExpectedHTMLWhenExecutedSequentially() { + let storage = TextStorage() + let mockDelegate = MockAttachmentsDelegate() + storage.attachmentsDelegate = mockDelegate + + storage.replaceRangeWithHorizontalRuler(.zero) + storage.replaceRangeWithHorizontalRuler(.zero) + let html = storage.getHTML() + + XCTAssertEqual(html, "

") + } + /// This test check if the insertion of an horizontal ruler over an image attachment works correctly and the hr tag is inserted /// - func testInsertHorizontalRulerOverImage() { + func testReplaceRangeWithHorizontalRulerRulerOverImage() { let storage = TextStorage() let mockDelegate = MockAttachmentsDelegate() storage.attachmentsDelegate = mockDelegate let _ = storage.insertImage(sourceURL: URL(string: "https://wordpress.com")!, atPosition: 0, placeHolderImage: UIImage()) - storage.insertHorizontalRuler(at: NSRange(location: 0, length:1)) + storage.replaceRangeWithHorizontalRuler(NSRange(location: 0, length:1)) let html = storage.getHTML() XCTAssertEqual(html, "
") } + + /// This test check if the insertion of a Comment Attachment works correctly and the expected tag gets inserted + /// + func testReplaceRangeWithCommentAttachmentGeneratesExpectedHTMLComment() { + let storage = TextStorage() + let mockDelegate = MockAttachmentsDelegate() + storage.attachmentsDelegate = mockDelegate + + storage.replaceRangeWithCommentAttachment(.zero, text: "more", attributes: [:]) + let html = storage.getHTML() + + XCTAssertEqual(html, "") + } + + /// This test check if the insertion of a Comment Attachment works correctly and the expected tag gets inserted + /// + func testReplaceRangeWithCommentAttachmentDoNotCrashTheEditorWhenCalledSequentially() { + let storage = TextStorage() + let mockDelegate = MockAttachmentsDelegate() + storage.attachmentsDelegate = mockDelegate + + storage.replaceRangeWithCommentAttachment(.zero, text: "more", attributes: [:]) + storage.replaceRangeWithCommentAttachment(.zero, text: "some other comment should go here", attributes: [:]) + + let html = storage.getHTML() + + XCTAssertEqual(html, "") + } + + /// This test verifies if we can delete all the content from a storage object that has html with a comment + /// + func testDeleteAllSelectionWhenContentHasComments() { + let storage = TextStorage() + let mockDelegate = MockAttachmentsDelegate() + storage.attachmentsDelegate = mockDelegate + + let commentString = "This is a comment" + let html = "" + storage.setHTML(html, withDefaultFontDescriptor: UIFont.systemFont(ofSize: 14).fontDescriptor) + storage.replaceCharacters(in: NSRange(location: 0, length: 1), with: NSAttributedString(string: "")) + + let resultHTML = storage.getHTML() + + XCTAssertEqual(String(), resultHTML) + } } diff --git a/AztecTests/TextViewTests.swift b/AztecTests/TextViewTests.swift index 826cbc82f..81c67d433 100644 --- a/AztecTests/TextViewTests.swift +++ b/AztecTests/TextViewTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import Aztec import Gridicons -class AztecVisualtextViewTests: XCTestCase { +class AztecVisualTextViewTests: XCTestCase { override func setUp() { super.setUp() @@ -16,6 +16,12 @@ class AztecVisualtextViewTests: XCTestCase { // MARK: - TextView construction + func createEmptyTextView() -> Aztec.TextView { + let richTextView = Aztec.TextView(defaultFont: UIFont.systemFont(ofSize: 14), defaultMissingImage: Gridicon.iconOfType(.attachment)) + + return richTextView + } + func createTextView(withHTML html: String) -> Aztec.TextView { let richTextView = Aztec.TextView(defaultFont: UIFont.systemFont(ofSize: 14), defaultMissingImage: Gridicon.iconOfType(.attachment)) @@ -242,6 +248,18 @@ class AztecVisualtextViewTests: XCTestCase { XCTAssert(!textView.formatIdentifiersSpanningRange(range).contains(.unorderedlist)) } + /// This test was created to prevent regressions related to this issue: + /// https://github.com/wordpress-mobile/WordPress-Aztec-iOS/issues/350 + /// + func testToggleBlockquoteAndStrikethrough() { + let textView = createEmptyTextView() + + textView.toggleStrikethrough(range: NSRange.zero) + textView.toggleBlockquote(range: NSRange.zero) + + // The test not crashing would be successful. + } + // MARK: - Test Attributes Exist func check(textView: TextView, range:NSRange, forIndentifier identifier: FormattingIdentifier) -> Bool { @@ -466,6 +484,46 @@ class AztecVisualtextViewTests: XCTestCase { XCTAssertEqual(textView.getHTML(), "

HelloWorld!

") } + /// Tests that deleting a newline works by merging the component around it. + /// + /// Input: + /// - Initial HTML: "List
  • first
  • second
  • third
" + /// - Deletion range: (loc: 4, len 1) + /// - Second deletion range: (loc: 9, len: 1) + /// - Third deletion range: (loc: 15, len: 1) + /// + /// Output: + /// - Final HTML: "Listfirstsecond" + /// + func testDeleteNewline5() { + + let textView = createTextView(withHTML: "List
  • first
  • second
  • third
") + + let rangeStart = textView.position(from: textView.beginningOfDocument, offset: 4)! + let rangeEnd = textView.position(from: rangeStart, offset: 1)! + let range = textView.textRange(from: rangeStart, to: rangeEnd)! + + textView.replace(range, withText: "") + + XCTAssertEqual(textView.getHTML(), "Listfirst
  • second
  • third
") + + let rangeStart2 = textView.position(from: textView.beginningOfDocument, offset: 9)! + let rangeEnd2 = textView.position(from: rangeStart2, offset: 1)! + let range2 = textView.textRange(from: rangeStart2, to: rangeEnd2)! + + textView.replace(range2, withText: "") + + XCTAssertEqual(textView.getHTML(), "Listfirstsecond
  • third
") + + let rangeStart3 = textView.position(from: textView.beginningOfDocument, offset: 15)! + let rangeEnd3 = textView.position(from: rangeStart3, offset: 1)! + let range3 = textView.textRange(from: rangeStart3, to: rangeEnd3)! + + textView.replace(range3, withText: "") + + XCTAssertEqual(textView.getHTML(), "Listfirstsecondthird") + } + /// Tests that deleting a newline works at the end of text with paragraph with header before works. /// /// Input: @@ -509,4 +567,13 @@ class AztecVisualtextViewTests: XCTestCase { XCTAssertEqual(textView.getHTML(), "\(linkTitle)") } + + func testToggleBlockquoteWriteOneCharAndDelete() { + let textView = createEmptyTextView() + + textView.toggleBlockquote(range: NSRange.zero) + textView.insertText("A") + textView.deleteBackward() + // The test not crashing would be successful. + } } diff --git a/Example/AztecExample.xcodeproj/project.pbxproj b/Example/AztecExample.xcodeproj/project.pbxproj index 7daa74605..6e9c10116 100644 --- a/Example/AztecExample.xcodeproj/project.pbxproj +++ b/Example/AztecExample.xcodeproj/project.pbxproj @@ -18,6 +18,8 @@ 607FACDB1AFB9204008FA782 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 607FACD91AFB9204008FA782 /* Main.storyboard */; }; 607FACDD1AFB9204008FA782 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDC1AFB9204008FA782 /* Images.xcassets */; }; 607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */; }; + B570B1C91E82D332008CF41E /* MoreAttachmentRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B570B1C81E82D332008CF41E /* MoreAttachmentRenderer.swift */; }; + B570B1CC1E82D343008CF41E /* CommentAttachmentRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B570B1CB1E82D343008CF41E /* CommentAttachmentRenderer.swift */; }; E63EF92B1D36A60B00B5BA4B /* EditorDemoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E63EF92A1D36A60B00B5BA4B /* EditorDemoController.swift */; }; FF6691C21E76CF9200C6A703 /* OptionsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6691C11E76CF9200C6A703 /* OptionsTableView.swift */; }; FF9AF5481DB0E4E200C42ED3 /* AttachmentDetailsViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FF9AF5471DB0E4E200C42ED3 /* AttachmentDetailsViewController.storyboard */; }; @@ -93,6 +95,8 @@ 607FACDF1AFB9204008FA782 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; 607FACE51AFB9204008FA782 /* AztecExample-Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "AztecExample-Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 607FACEA1AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B570B1C81E82D332008CF41E /* MoreAttachmentRenderer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoreAttachmentRenderer.swift; sourceTree = ""; }; + B570B1CB1E82D343008CF41E /* CommentAttachmentRenderer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommentAttachmentRenderer.swift; sourceTree = ""; }; E63EF92A1D36A60B00B5BA4B /* EditorDemoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditorDemoController.swift; sourceTree = ""; }; FF6691C11E76CF9200C6A703 /* OptionsTableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OptionsTableView.swift; sourceTree = ""; }; FF9AF5471DB0E4E200C42ED3 /* AttachmentDetailsViewController.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = AttachmentDetailsViewController.storyboard; sourceTree = ""; }; @@ -174,6 +178,7 @@ 607FACD21AFB9204008FA782 /* Example for WordPress-Aztec-iOS */ = { isa = PBXGroup; children = ( + B570B1CD1E82D349008CF41E /* Renders */, 599F256F1D8BCF57002871D6 /* AppDelegate.swift */, 59D287391D8C599B00B99C80 /* AttachmentDetailsViewController.swift */, FF9AF5471DB0E4E200C42ED3 /* AttachmentDetailsViewController.storyboard */, @@ -214,6 +219,15 @@ name = "Supporting Files"; sourceTree = ""; }; + B570B1CD1E82D349008CF41E /* Renders */ = { + isa = PBXGroup; + children = ( + B570B1CB1E82D343008CF41E /* CommentAttachmentRenderer.swift */, + B570B1C81E82D332008CF41E /* MoreAttachmentRenderer.swift */, + ); + name = Renders; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -352,8 +366,10 @@ 599F25701D8BCF57002871D6 /* AppDelegate.swift in Sources */, 59D2873B1D8C599B00B99C80 /* AttachmentDetailsViewController.swift in Sources */, FF6691C21E76CF9200C6A703 /* OptionsTableView.swift in Sources */, + B570B1CC1E82D343008CF41E /* CommentAttachmentRenderer.swift in Sources */, E63EF92B1D36A60B00B5BA4B /* EditorDemoController.swift in Sources */, 607FACD81AFB9204008FA782 /* ViewController.swift in Sources */, + B570B1C91E82D332008CF41E /* MoreAttachmentRenderer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Example/Cartfile.resolved b/Example/Cartfile.resolved index 85d110d48..7fbad5f3b 100644 --- a/Example/Cartfile.resolved +++ b/Example/Cartfile.resolved @@ -1 +1 @@ -github "Automattic/Gridicons-iOS" "8582ace3eae40bfb56f5e341c76295ccc830eaed" +github "Automattic/Gridicons-iOS" "977cbfaa88d35e2fdaceef496be211cbe4578f8b" diff --git a/Example/Example/CommentAttachmentRenderer.swift b/Example/Example/CommentAttachmentRenderer.swift new file mode 100644 index 000000000..c801d52ca --- /dev/null +++ b/Example/Example/CommentAttachmentRenderer.swift @@ -0,0 +1,78 @@ +import Foundation +import UIKit +import Aztec + + +final class CommentAttachmentRenderer { + + /// Comment Attachment Text + /// + let defaultText = NSLocalizedString("[COMMENT]", comment: "Comment Attachment Label") + + /// Text Color + /// + var textColor = UIColor.gray + + /// Text Font + /// + var textFont: UIFont + + + /// Default Initializer + /// + init?(font: UIFont) { + self.textFont = font + } +} + + +// MARK: - TextViewCommentsDelegate Methods +// +extension CommentAttachmentRenderer: TextViewCommentsDelegate { + + func textView(_ textView: TextView, imageForComment attachment: CommentAttachment, with size: CGSize) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(size, false, 0) + + let message = messageAttributedString() + let targetRect = boundingRect(for: message, size: size) + + message.draw(in: targetRect) + + let result = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return result + } + + func textView(_ textView: TextView, boundsForComment attachment: CommentAttachment, with lineFragment: CGRect) -> CGRect { + let message = messageAttributedString() + + let size = CGSize(width: lineFragment.size.width, height: lineFragment.size.height) + var rect = boundingRect(for: message, size: size) + rect.origin.y = textFont.descender + + return rect.integral + } +} + + +// MARK: - Private Methods +// +private extension CommentAttachmentRenderer { + + func boundingRect(for message: NSAttributedString, size: CGSize) -> CGRect { + let targetBounds = message.boundingRect(with: size, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil) + let targetPosition = CGPoint(x: ((size.width - targetBounds.width) * 0.5), y: ((size.height - targetBounds.height) * 0.5)) + + return CGRect(origin: targetPosition, size: targetBounds.size) + } + + func messageAttributedString() -> NSAttributedString { + let attributes: [String: Any] = [ + NSForegroundColorAttributeName: textColor, + NSFontAttributeName: textFont + ] + + return NSAttributedString(string: defaultText, attributes: attributes) + } +} diff --git a/Example/Example/EditorDemoController.swift b/Example/Example/EditorDemoController.swift index 4a2eece76..963783bec 100644 --- a/Example/Example/EditorDemoController.swift +++ b/Example/Example/EditorDemoController.swift @@ -6,16 +6,12 @@ import UIKit import MobileCoreServices class EditorDemoController: UIViewController { - static let margin = CGFloat(20) - static let defaultContentFont = UIFont.systemFont(ofSize: 14) - fileprivate var mediaErrorMode = false - lazy var headers: [HeaderFormatter.HeaderType] = [.none, .h1, .h2, .h3, .h4, .h5, .h6] + fileprivate var mediaErrorMode = false fileprivate(set) lazy var richTextView: Aztec.TextView = { - let defaultMissingImage = Gridicon.iconOfType(.image) - let textView = Aztec.TextView(defaultFont: type(of: self).defaultContentFont, defaultMissingImage: defaultMissingImage) + let textView = Aztec.TextView(defaultFont: Constants.defaultContentFont, defaultMissingImage: Constants.defaultMissingImage) let toolbar = self.createToolbar(htmlMode: false) @@ -25,6 +21,7 @@ class EditorDemoController: UIViewController { textView.delegate = self textView.formattingDelegate = self textView.mediaDelegate = self + textView.commentsDelegate = self return textView }() @@ -167,24 +164,24 @@ class EditorDemoController: UIViewController { private func configureConstraints() { NSLayoutConstraint.activate([ - titleTextField.leftAnchor.constraint(equalTo: view.leftAnchor, constant: type(of: self).margin), - titleTextField.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -type(of: self).margin), - titleTextField.topAnchor.constraint(equalTo: view.topAnchor, constant: type(of: self).margin), + titleTextField.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Constants.margin), + titleTextField.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Constants.margin), + titleTextField.topAnchor.constraint(equalTo: view.topAnchor, constant: Constants.margin), titleTextField.heightAnchor.constraint(equalToConstant: titleTextField.font!.lineHeight) ]) NSLayoutConstraint.activate([ - separatorView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: type(of: self).margin), - separatorView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -type(of: self).margin), - separatorView.topAnchor.constraint(equalTo: titleTextField.bottomAnchor, constant: type(of: self).margin), + separatorView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Constants.margin), + separatorView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Constants.margin), + separatorView.topAnchor.constraint(equalTo: titleTextField.bottomAnchor, constant: Constants.margin), separatorView.heightAnchor.constraint(equalToConstant: separatorView.frame.height) ]) NSLayoutConstraint.activate([ - richTextView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: type(of: self).margin), - richTextView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -type(of: self).margin), - richTextView.topAnchor.constraint(equalTo: separatorView.bottomAnchor, constant: type(of: self).margin), - richTextView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -type(of: self).margin) + richTextView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Constants.margin), + richTextView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Constants.margin), + richTextView.topAnchor.constraint(equalTo: separatorView.bottomAnchor, constant: Constants.margin), + richTextView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -Constants.margin) ]) NSLayoutConstraint.activate([ @@ -198,7 +195,7 @@ class EditorDemoController: UIViewController { private func configureDefaultProperties(for textView: UITextView, using formatBar: Aztec.FormatBar, accessibilityLabel: String) { textView.accessibilityLabel = accessibilityLabel - textView.font = EditorDemoController.defaultContentFont + textView.font = Constants.defaultContentFont textView.inputAccessoryView = formatBar textView.keyboardDismissMode = .interactive textView.textColor = UIColor.darkText @@ -377,6 +374,8 @@ extension EditorDemoController : Aztec.FormatBarDelegate { toggleEditingMode() case .header, .header1, .header2, .header3, .header4, .header5, .header6: toggleHeader() + case .more: + insertMoreAttachment() case .horizontalruler: insertHorizontalRuler() } @@ -419,7 +418,7 @@ extension EditorDemoController : Aztec.FormatBarDelegate { } func insertHorizontalRuler() { - richTextView.replaceWithHorizontalRuler(at: richTextView.selectedRange) + richTextView.replaceRangeWithHorizontalRuler(richTextView.selectedRange) } func toggleHeader() { @@ -428,17 +427,17 @@ extension EditorDemoController : Aztec.FormatBarDelegate { changeRichTextInputView(to: nil) return } - let headerOptions = headers.map { (headerType) -> NSAttributedString in + let headerOptions = Constants.headers.map { (headerType) -> NSAttributedString in NSAttributedString(string: headerType.description, attributes:[NSFontAttributeName: UIFont.systemFont(ofSize: headerType.fontSize)]) } let headerPicker = OptionsTableView(frame: CGRect(x: 0, y: 0, width: self.view.frame.size.width, height: 200), options: headerOptions) headerPicker.autoresizingMask = [.flexibleHeight, .flexibleWidth] headerPicker.onSelect = { selected in - self.richTextView.toggleHeader(self.headers[selected], range: self.richTextView.selectedRange) + self.richTextView.toggleHeader(Constants.headers[selected], range: self.richTextView.selectedRange) self.changeRichTextInputView(to: nil) } - if let selectedHeader = headers.index(of:self.headerLevelForSelectedText()) { + if let selectedHeader = Constants.headers.index(of: self.headerLevelForSelectedText()) { headerPicker.selectRow(at: IndexPath(row: selectedHeader, section: 0), animated: false, scrollPosition: .top) } changeRichTextInputView(to: headerPicker) @@ -490,6 +489,10 @@ extension EditorDemoController : Aztec.FormatBarDelegate { showLinkDialog(forURL: linkURL, title: linkTitle, range: linkRange) } + func insertMoreAttachment() { + richTextView.replaceRangeWithCommentAttachment(richTextView.selectedRange, text: Constants.moreAttachmentText) + } + func showLinkDialog(forURL url: URL?, title: String?, range: NSRange) { let isInsertingNewLink = (url == nil) @@ -650,7 +653,8 @@ extension EditorDemoController : Aztec.FormatBarDelegate { FormatBarItem(image: Gridicon.iconOfType(.listUnordered), identifier: .unorderedlist), FormatBarItem(image: Gridicon.iconOfType(.listOrdered), identifier: .orderedlist), FormatBarItem(image: Gridicon.iconOfType(.link), identifier: .link), - FormatBarItem(image: Gridicon.iconOfType(.minusSmall), identifier: .horizontalruler) + FormatBarItem(image: Gridicon.iconOfType(.minusSmall), identifier: .horizontalruler), + FormatBarItem(image: Gridicon.iconOfType(.readMore), identifier: .more) ] } @@ -662,8 +666,37 @@ extension EditorDemoController : Aztec.FormatBarDelegate { } -extension EditorDemoController: TextViewMediaDelegate -{ + +extension EditorDemoController: TextViewCommentsDelegate { + + func textView(_ textView: TextView, imageForComment attachment: CommentAttachment, with size: CGSize) -> UIImage? { + if let render = MoreAttachmentRenderer(attachment: attachment) { + return render.textView(textView, imageForComment: attachment, with: size) + } + + if let render = CommentAttachmentRenderer(font: Constants.defaultContentFont) { + return render.textView(textView, imageForComment: attachment, with: size) + } + + return nil + } + + func textView(_ textView: TextView, boundsForComment attachment: CommentAttachment, with lineFragment: CGRect) -> CGRect { + if let render = MoreAttachmentRenderer(attachment: attachment) { + return render.textView(textView, boundsForComment: attachment, with: lineFragment) + } + + if let render = CommentAttachmentRenderer(font: Constants.defaultContentFont) { + return render.textView(textView, boundsForComment: attachment, with: lineFragment) + } + + return .zero + } +} + + +extension EditorDemoController: TextViewMediaDelegate { + func textView(_ textView: TextView, imageAtUrl url: URL, onSuccess success: @escaping (UIImage) -> Void, onFailure failure: @escaping (Void) -> Void) -> UIImage { let task = URLSession.shared.dataTask(with: url, completionHandler: { (data, urlResponse, error) in @@ -872,3 +905,15 @@ private extension EditorDemoController present(navigationController, animated: true, completion: nil) } } + + +extension EditorDemoController { + + struct Constants { + static let defaultContentFont = UIFont.systemFont(ofSize: 14) + static let defaultMissingImage = Gridicon.iconOfType(.image) + static let headers: [HeaderFormatter.HeaderType] = [.none, .h1, .h2, .h3, .h4, .h5, .h6] + static let margin = CGFloat(20) + static let moreAttachmentText = "more" + } +} diff --git a/Example/Example/MoreAttachmentRenderer.swift b/Example/Example/MoreAttachmentRenderer.swift new file mode 100644 index 000000000..247c37d87 --- /dev/null +++ b/Example/Example/MoreAttachmentRenderer.swift @@ -0,0 +1,88 @@ +import Foundation +import UIKit +import Aztec + + +// MARK: - MoreAttachmentRenderer: Renders More Comments! +// +final class MoreAttachmentRenderer { + + /// Attachment to be rendered + /// + let attachment: CommentAttachment + + /// Text Color + /// + var textColor = UIColor.gray + + + /// Default Initializer: Returns *nil* whenever the Attachment's text is not *more*. + /// This render is expected to only work with `` comments! + /// + init?(attachment: CommentAttachment) { + self.attachment = attachment + + guard attachment.text == Constants.defaultCommentText else { + return nil + } + } +} + + +// MARK: - TextViewCommentsDelegate Methods +// +extension MoreAttachmentRenderer: TextViewCommentsDelegate { + + func textView(_ textView: TextView, imageForComment attachment: CommentAttachment, with size: CGSize) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(size, false, 0) + + let label = attachment.text.uppercased() + let attributes = [NSForegroundColorAttributeName: textColor] + let colorMessage = NSAttributedString(string: label, attributes: attributes) + + let textRect = colorMessage.boundingRect(with: size, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil) + let textPosition = CGPoint(x: ((size.width - textRect.width) * 0.5), y: ((size.height - textRect.height) * 0.5)) + colorMessage.draw(in: CGRect(origin: textPosition , size: CGSize(width: size.width, height: textRect.size.height))) + + let path = UIBezierPath() + + let dashes = [ Constants.defaultDashCount, Constants.defaultDashCount ] + path.setLineDash(dashes, count: dashes.count, phase: 0.0) + path.lineWidth = Constants.defaultDashWidth + + let centerY = round(size.height * 0.5) + path.move(to: CGPoint(x: 0, y: centerY)) + path.addLine(to: CGPoint(x: ((size.width - textRect.width) * 0.5) - Constants.defaultDashWidth, y: centerY)) + + path.move(to: CGPoint(x:((size.width + textRect.width) * 0.5) + Constants.defaultDashWidth, y: centerY)) + path.addLine(to: CGPoint(x: size.width, y: centerY)) + + textColor.setStroke() + path.stroke() + + let result = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return result + } + + func textView(_ textView: TextView, boundsForComment attachment: CommentAttachment, with lineFragment: CGRect) -> CGRect { + let padding = textView.textContainer.lineFragmentPadding + let width = lineFragment.width - padding * 2 + + return CGRect(origin: .zero, size: CGSize(width: width, height: Constants.defaultHeight)) + } +} + + +// MARK: - Constants +// +extension MoreAttachmentRenderer { + + struct Constants { + static let defaultDashCount = CGFloat(8.0) + static let defaultDashWidth = CGFloat(2.0) + static let defaultHeight = CGFloat(44.0) + static let defaultCommentText = "more" + } +} diff --git a/Example/Example/SampleContent/content.html b/Example/Example/SampleContent/content.html index 9ca412942..7b7b276ec 100644 --- a/Example/Example/SampleContent/content.html +++ b/Example/Example/SampleContent/content.html @@ -14,6 +14,7 @@

Character Styles

Italic text
Underlined text
Strikethrough
+Colors
I'm a link!
diff --git a/WordPress-Aztec-iOS.podspec b/WordPress-Aztec-iOS.podspec index ee293f2f0..ebb6fcdba 100644 --- a/WordPress-Aztec-iOS.podspec +++ b/WordPress-Aztec-iOS.podspec @@ -8,7 +8,7 @@ Pod::Spec.new do |s| s.name = 'WordPress-Aztec-iOS' - s.version = '0.5a7' + s.version = '0.5a7.1' s.summary = 'The native HTML Editor.' # This description is used to generate tags and improve search results. @@ -38,6 +38,6 @@ Pod::Spec.new do |s| s.xcconfig = {'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2'} s.preserve_paths = 'Aztec/Modulemaps/libxml2/*' - s.dependency 'Gridicons', '0.4' + s.dependency 'Gridicons', '0.5' end