diff --git a/Aztec.xcodeproj/project.pbxproj b/Aztec.xcodeproj/project.pbxproj index 5042b71ff..a76e24ede 100644 --- a/Aztec.xcodeproj/project.pbxproj +++ b/Aztec.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 40A2986D1FD61B0C00AEDF3B /* ElementConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A2986C1FD61B0C00AEDF3B /* ElementConverter.swift */; }; 40A298711FD61B6F00AEDF3B /* ImageElementConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A298701FD61B6F00AEDF3B /* ImageElementConverter.swift */; }; 40A298731FD61E1900AEDF3B /* VideoElementConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A298721FD61E1900AEDF3B /* VideoElementConverter.swift */; }; + 5608841E27DBA33600DA8AA7 /* MarkStringAttributeConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5608841D27DBA33600DA8AA7 /* MarkStringAttributeConverter.swift */; }; 568FF25827552BFF0057B2E3 /* MarkFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 568FF25727552BFF0057B2E3 /* MarkFormatter.swift */; }; 594C9D6F1D8BE61F00D74542 /* Aztec.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 5951CB8E1D8BC93600E1866F /* Aztec.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 594C9D731D8BE6C300D74542 /* InAttributeConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59FEA06B1D8BDFA700D138DF /* InAttributeConverterTests.swift */; }; @@ -288,6 +289,7 @@ 40A298701FD61B6F00AEDF3B /* ImageElementConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageElementConverter.swift; sourceTree = "<group>"; }; 40A298721FD61E1900AEDF3B /* VideoElementConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoElementConverter.swift; sourceTree = "<group>"; }; 50A1CC6E250FEA93001D5517 /* LICENSE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = LICENSE.md; sourceTree = "<group>"; }; + 5608841D27DBA33600DA8AA7 /* MarkStringAttributeConverter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MarkStringAttributeConverter.swift; sourceTree = "<group>"; }; 568FF25727552BFF0057B2E3 /* MarkFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkFormatter.swift; sourceTree = "<group>"; }; 5951CB8E1D8BC93600E1866F /* Aztec.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Aztec.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5951CB921D8BC93600E1866F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; @@ -1101,6 +1103,7 @@ F15BA60C215159A600424120 /* ItalicStringAttributeConverter.swift */, FF94935D245738AC0085ABB3 /* SuperscriptStringAttributeConverter.swift */, FF949361245744090085ABB3 /* SubscriptStringAttributeConverter.swift */, + 5608841D27DBA33600DA8AA7 /* MarkStringAttributeConverter.swift */, F15BA60E21515C0F00424120 /* UnderlineStringAttributeConverter.swift */, ); path = Implementations; @@ -1547,6 +1550,7 @@ F1584794203C94AC00EE05A1 /* Dictionary+AttributedStringKey.swift in Sources */, B572AC281E817CFE008948C2 /* CommentAttachment.swift in Sources */, F1E1D5881FEC52EE0086B339 /* GenericElementConverter.swift in Sources */, + 5608841E27DBA33600DA8AA7 /* MarkStringAttributeConverter.swift in Sources */, F1FA0E861E6EF514009D98EE /* Node.swift in Sources */, FFD3C1712344DB4E00AE8DA0 /* ForegroundColorCSSAttributeMatcher.swift in Sources */, FFD3C1732344DCA900AE8DA0 /* ForegroundColorElementAttributeConverter.swift in Sources */, diff --git a/Aztec/Classes/Converters/ElementsToAttributedString/Implementations/GenericElementConverter.swift b/Aztec/Classes/Converters/ElementsToAttributedString/Implementations/GenericElementConverter.swift index aca55ab0c..62ef6fd7d 100644 --- a/Aztec/Classes/Converters/ElementsToAttributedString/Implementations/GenericElementConverter.swift +++ b/Aztec/Classes/Converters/ElementsToAttributedString/Implementations/GenericElementConverter.swift @@ -36,6 +36,7 @@ class GenericElementConverter: ElementConverter { lazy var liFormatter = LiFormatter() lazy var superscriptFormatter = SuperscriptFormatter() lazy var subscriptFormatter = SubscriptFormatter() + lazy var markFormatter = MarkFormatter() public lazy var elementFormattersMap: [Element: AttributeFormatter] = { return [ @@ -60,6 +61,7 @@ class GenericElementConverter: ElementConverter { .li: self.liFormatter, .sup: self.superscriptFormatter, .sub: self.subscriptFormatter, + .mark: self.markFormatter, ] }() diff --git a/Aztec/Classes/Converters/StringAttributesToAttributes/Implementations/MarkStringAttributeConverter.swift b/Aztec/Classes/Converters/StringAttributesToAttributes/Implementations/MarkStringAttributeConverter.swift new file mode 100644 index 000000000..14be4b5bc --- /dev/null +++ b/Aztec/Classes/Converters/StringAttributesToAttributes/Implementations/MarkStringAttributeConverter.swift @@ -0,0 +1,42 @@ +import Foundation +import UIKit + + +/// Converts the mark style information from string attributes and aggregates it into an +/// existing array of element nodes. +/// +open class MarkStringAttributeConverter: StringAttributeConverter { + + private let toggler = HTMLStyleToggler(defaultElement: .mark, cssAttributeMatcher: ForegroundColorCSSAttributeMatcher()) + + public func convert( + attributes: [NSAttributedString.Key: Any], + andAggregateWith elementNodes: [ElementNode]) -> [ElementNode] { + + var elementNodes = elementNodes + + // We add the representation right away, if it exists... as it could contain attributes beyond just this + // style. The enable and disable methods below can modify this as necessary. + // + + if let elementNode = attributes.storedElement(for: NSAttributedString.Key.markHtmlRepresentation) { + elementNodes.append(elementNode) + } + + if shouldEnableMarkElement(for: attributes) { + return toggler.enable(in: elementNodes) + } else { + return toggler.disable(in: elementNodes) + } + } + + // MARK: - Style Detection + + func shouldEnableMarkElement(for attributes: [NSAttributedString.Key: Any]) -> Bool { + return isMark(for: attributes) + } + + func isMark(for attributes: [NSAttributedString.Key: Any]) -> Bool { + return attributes[.markHtmlRepresentation] != nil + } +} diff --git a/Aztec/Classes/Formatters/Implementations/MarkFormatter.swift b/Aztec/Classes/Formatters/Implementations/MarkFormatter.swift index 61da0a621..3022db0f8 100644 --- a/Aztec/Classes/Formatters/Implementations/MarkFormatter.swift +++ b/Aztec/Classes/Formatters/Implementations/MarkFormatter.swift @@ -24,8 +24,8 @@ class MarkFormatter: AttributeFormatter { func remove(from attributes: [NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any] { var resultingAttributes = attributes + resultingAttributes[.foregroundColor] = placeholderAttributes![.foregroundColor] resultingAttributes.removeValue(forKey: .markHtmlRepresentation) - return resultingAttributes } diff --git a/Aztec/Classes/NSAttributedString/Conversions/AttributedStringParser.swift b/Aztec/Classes/NSAttributedString/Conversions/AttributedStringParser.swift index e64f39e3d..5c2d5501c 100644 --- a/Aztec/Classes/NSAttributedString/Conversions/AttributedStringParser.swift +++ b/Aztec/Classes/NSAttributedString/Conversions/AttributedStringParser.swift @@ -29,6 +29,7 @@ class AttributedStringParser { UnderlineStringAttributeConverter(), SuperscriptStringAttributeConverter(), SubscriptStringAttributeConverter(), + MarkStringAttributeConverter(), ] // MARK: - Attachment Converters diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index 1abfc7f50..e6941e025 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -145,7 +145,8 @@ open class TextStorage: NSTextStorage { private func preprocessAttributesForInsertion(_ attributedString: NSAttributedString) -> NSAttributedString { let stringWithAttachments = preprocessAttachmentsForInsertion(attributedString) - let preprocessedString = preprocessHeadingsForInsertion(stringWithAttachments) + let stringWithHeadings = preprocessHeadingsForInsertion(stringWithAttachments) + let preprocessedString = preprocessMarkForInsertion(stringWithHeadings) return preprocessedString } @@ -253,6 +254,41 @@ open class TextStorage: NSTextStorage { return processedString } + /// Preprocesses an attributed string that is missing a `markHtmlRepresentation` attribute for insertion in the storage, this reuses the same behavior as preprocessHeadingsForInsertion + /// + /// - Important: This method adds the `markHtmlRepresentation` attribute if it determines the string should contain it. + /// This works around a problem where autocorrected text didn't contain the attribute. This may change in future versions. + /// + /// - Parameters: + /// - attributedString: the string we need to preprocess. + /// + /// - Returns: the preprocessed string. + /// + fileprivate func preprocessMarkForInsertion(_ attributedString: NSAttributedString) -> NSAttributedString { + guard textStore.length > 0, attributedString.length > 0 else { + return attributedString + } + + // Get the attributes of the start of the current string in storage. + let currentAttrs = attributes(at: 0, effectiveRange: nil) + + guard + // the text currently in storage has a markHtmlRepresentation key + let markSize = currentAttrs[.markHtmlRepresentation], + // the text coming in doesn't have a markHtmlRepresentation key + attributedString.attribute(.markHtmlRepresentation, at: 0, effectiveRange: nil) == nil + else { + // Either the mark attribute wasn't present in the existing string, + // or the attributed string already had it. + return attributedString + } + + let processedString = NSMutableAttributedString(attributedString: attributedString) + processedString.addAttribute(.markHtmlRepresentation, value: markSize, range: attributedString.rangeOfEntireString) + + return processedString + } + fileprivate func detectAttachmentRemoved(in range: NSRange) { // Ref. https://github.com/wordpress-mobile/AztecEditor-iOS/issues/727: // If the delegate is not set, we *Explicitly* do not want to crash here. diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 2d83526bb..47157f122 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -1160,6 +1160,8 @@ open class TextView: UITextView { let formatter = MarkFormatter() formatter.placeholderAttributes = self.defaultAttributes toggle(formatter: formatter, atRange: range) + + formattingDelegate?.textViewCommandToggledAStyle() } /// Replaces with an horizontal ruler on the specified range diff --git a/AztecTests/TextKit/TextStorageTests.swift b/AztecTests/TextKit/TextStorageTests.swift index 9847e2fcb..0b9712e95 100644 --- a/AztecTests/TextKit/TextStorageTests.swift +++ b/AztecTests/TextKit/TextStorageTests.swift @@ -633,4 +633,31 @@ class TextStorageTests: XCTestCase { XCTAssertEqual(storage.string, "Hello I'm a paragraph") XCTAssertNil(finalAttributes[.headingRepresentation]) } + + /// Verifies that missing Mark attributes are retained on string replacements when appropriate + /// + func testMissingMarkAttributeIsRetained() { + let formatter = MarkFormatter() + storage.replaceCharacters(in: storage.rangeOfEntireString, with: "Hello i'm a text highlighted") + formatter.applyAttributes(to: storage, at: storage.rangeOfEntireString) + + let originalAttributes = storage.attributes(at: 0, effectiveRange: nil) + XCTAssertEqual(storage.string, "Hello i'm a text highlighted") + XCTAssertEqual(originalAttributes.count, 2) + XCTAssertNotNil(originalAttributes[.markHtmlRepresentation]) + + let autoCorrectedAttributes = originalAttributes.filter { $0.key != .markHtmlRepresentation } + + let autoCorrectedString = NSAttributedString( + string: "I'm", + attributes: autoCorrectedAttributes + ) + + let range = NSRange(location: 6, length: 3) + storage.replaceCharacters(in: range, with: autoCorrectedString) + + let finalAttributes = storage.attributes(at: range.location, effectiveRange: nil) + XCTAssertEqual(storage.string, "Hello I'm a text highlighted") + XCTAssertEqual(originalAttributes.keys, finalAttributes.keys) + } }