Skip to content

Commit

Permalink
Merge pull request #1352 from wordpress-mobile/feature/mark-formattin…
Browse files Browse the repository at this point in the history
…g-improvements

Improve Mark formatting
  • Loading branch information
Gerardo Pacheco authored Mar 21, 2024
2 parents a42d2de + 7b9cc63 commit f6501a7
Show file tree
Hide file tree
Showing 8 changed files with 189 additions and 12 deletions.
4 changes: 4 additions & 0 deletions Aztec.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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>"; };
Expand Down Expand Up @@ -1101,6 +1103,7 @@
F15BA60C215159A600424120 /* ItalicStringAttributeConverter.swift */,
FF94935D245738AC0085ABB3 /* SuperscriptStringAttributeConverter.swift */,
FF949361245744090085ABB3 /* SubscriptStringAttributeConverter.swift */,
5608841D27DBA33600DA8AA7 /* MarkStringAttributeConverter.swift */,
F15BA60E21515C0F00424120 /* UnderlineStringAttributeConverter.swift */,
);
path = Implementations;
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -60,6 +61,7 @@ class GenericElementConverter: ElementConverter {
.li: self.liFormatter,
.sup: self.superscriptFormatter,
.sub: self.subscriptFormatter,
.mark: self.markFormatter,
]
}()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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) {
let styleAttribute = elementNode.attributes.first(where: { $0.name == "style" })
if let elementStyle = styleAttribute?.value.toString() {
// Remove spaces between attribute name and value, and between style attributes.
let styleAttributes = elementStyle.replacingOccurrences(of: ": ", with: ":").replacingOccurrences(of: "; ", with: ";")
elementNode.attributes["style"] = .string(styleAttributes)
}
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
}
}
21 changes: 18 additions & 3 deletions Aztec/Classes/Formatters/Implementations/MarkFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,43 @@ import UIKit
class MarkFormatter: AttributeFormatter {

var placeholderAttributes: [NSAttributedString.Key: Any]?
var textColor: String?
var defaultTextColor: UIColor?

func applicationRange(for range: NSRange, in text: NSAttributedString) -> NSRange {
return range
}

func apply(to attributes: [NSAttributedString.Key: Any], andStore representation: HTMLRepresentation?) -> [NSAttributedString.Key: Any] {
var resultingAttributes = attributes
var resultingAttributes = attributes
let colorStyle = CSSAttribute(name: "color", value: textColor)
let backgroundColorStyle = CSSAttribute(name: "background-color", value: "rgba(0, 0, 0, 0)")

let styleAttribute = Attribute(type: .style, value: .inlineCss([backgroundColorStyle, colorStyle]))
let classAttribute = Attribute(type: .class, value: .string("has-inline-color"))

var representationToUse = HTMLRepresentation(for: .element(HTMLElementRepresentation.init(name: "mark", attributes: [])))
// Setting the HTML representation
var representationToUse = HTMLRepresentation(for: .element(HTMLElementRepresentation.init(name: "mark", attributes: [styleAttribute, classAttribute])))
if let requestedRepresentation = representation {
representationToUse = requestedRepresentation
}
resultingAttributes[.markHtmlRepresentation] = representationToUse

if let textColor = textColor {
resultingAttributes[.foregroundColor] = UIColor(hexString: textColor)
}

return resultingAttributes
}

func remove(from attributes: [NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any] {
var resultingAttributes = attributes

resultingAttributes.removeValue(forKey: .markHtmlRepresentation)
if defaultTextColor != nil {
resultingAttributes[.foregroundColor] = defaultTextColor
}

resultingAttributes.removeValue(forKey: .markHtmlRepresentation)
return resultingAttributes
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class AttributedStringParser {
UnderlineStringAttributeConverter(),
SuperscriptStringAttributeConverter(),
SubscriptStringAttributeConverter(),
MarkStringAttributeConverter(),
]

// MARK: - Attachment Converters
Expand Down
79 changes: 71 additions & 8 deletions Aztec/Classes/TextKit/TextStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,11 @@ open class TextStorage: NSTextStorage {

// MARK: - NSAttributedString preprocessing

private func preprocessAttributesForInsertion(_ attributedString: NSAttributedString) -> NSAttributedString {
private func preprocessAttributesForInsertion(_ attributedString: NSAttributedString, _ range: NSRange) -> NSAttributedString {
let stringWithAttachments = preprocessAttachmentsForInsertion(attributedString)
let preprocessedString = preprocessHeadingsForInsertion(stringWithAttachments)
let stringWithHeadings = preprocessHeadingsForInsertion(stringWithAttachments)

return preprocessedString
return stringWithHeadings
}

/// Preprocesses an attributed string's attachments for insertion in the storage.
Expand Down Expand Up @@ -253,6 +253,35 @@ open class TextStorage: NSTextStorage {
return processedString
}

/// Preprocesses an attributed string that is missing a `markHtmlRepresentation` attribute for insertion in the storage.
/// This method ensures that the `markHtmlRepresentation` attribute, if present in the current text storage,
/// is applied to the new attributed string being inserted. This is particularly useful for maintaining
/// mark formatting in scenarios like autocorrection or predictive text input.
///
/// - Important: This method adds the `markHtmlRepresentation` attribute to the new string if it's determined
/// that the string should contain it, based on existing attributes in the text storage.
/// This helps to overcome issues where autocorrected text does not carry over the `markHtmlRepresentation` attribute.
///
/// - Parameters:
/// - attributedString: The new string to be inserted.
/// - range: The range in the current text storage where the new string is to be inserted. This is used to determine
/// if `markHtmlRepresentation` should be applied to the new string.
///
/// - Returns: The preprocessed attributed string with `markHtmlRepresentation` applied if necessary.
///
fileprivate func preprocessMarkForInsertion(_ attributedString: NSAttributedString, _ range: NSRange) -> NSAttributedString {
let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)

if range.location < textStore.length && range.length > 0 {
let currentAttrs = textStore.attributes(at: range.location, effectiveRange: nil)

if let markAttribute = currentAttrs[.markHtmlRepresentation] {
mutableAttributedString.addAttribute(.markHtmlRepresentation, value: markAttribute, range: NSRange(location: 0, length: mutableAttributedString.length))
}
}
return mutableAttributedString
}

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.
Expand Down Expand Up @@ -304,14 +333,16 @@ open class TextStorage: NSTextStorage {
}

override open func replaceCharacters(in range: NSRange, with attrString: NSAttributedString) {

let preprocessedString = preprocessAttributesForInsertion(attrString)
let preprocessedString = preprocessAttributesForInsertion(attrString, range)

beginEditing()

detectAttachmentRemoved(in: range)
textStore.replaceCharacters(in: range, with: preprocessedString)

// Apply mark formatting to the replacement string
let markFormattedString = preprocessMarkForInsertion(preprocessedString, range)

textStore.replaceCharacters(in: range, with: markFormattedString)
replaceTextStoreString(range, with: attrString.string)

edited([.editedAttributes, .editedCharacters], range: range, changeInLength: attrString.length - range.length)
Expand All @@ -322,11 +353,15 @@ open class TextStorage: NSTextStorage {
override open func setAttributes(_ attrs: [NSAttributedString.Key: Any]?, range: NSRange) {
beginEditing()

// Ensure matching styles for the font and paragraph headers
let fixedAttributes = ensureMatchingFontAndParagraphHeaderStyles(beforeApplying: attrs ?? [:], at: range)

textStore.setAttributes(fixedAttributes, range: range)
// Adjust attributes for 'mark' formatting logic
let adjustedAttributes = adjustAttributesForMark(fixedAttributes, range: range)

textStore.setAttributes(adjustedAttributes, range: range)
edited(.editedAttributes, range: range, changeInLength: 0)

endEditing()
}

Expand Down Expand Up @@ -482,6 +517,34 @@ private extension TextStorage {
}
}

// MARK: - Mark Formatting Attribute Fixes
//
private extension TextStorage {
/// Adjusts text attributes to preserve the color of text marked with 'markHtmlRepresentation'.
///
/// This method checks if the specified range of text has the 'markHtmlRepresentation' attribute.
/// If it does, the method retains the existing color attribute to preserve the 'mark' formatting.
///
/// - Parameters:
/// - attrs: NSAttributedString attributes that are about to be applied.
/// - range: Range of the text being modified.
///
/// - Returns: Adjusted collection of attributes, preserving color for 'mark' formatted text.
///
private func adjustAttributesForMark(_ attrs: [NSAttributedString.Key: Any], range: NSRange) -> [NSAttributedString.Key: Any] {
var adjustedAttributes = attrs

// Check if the range has the 'markHtmlRepresentation' attribute
let hasMarkAttribute = attribute(.markHtmlRepresentation, at: range.location, effectiveRange: nil) != nil

// If the 'markHtmlRepresentation' attribute is present, retain the existing color
if hasMarkAttribute, let existingColor = textStore.attribute(.foregroundColor, at: range.location, effectiveRange: nil) as? UIColor {
adjustedAttributes[.foregroundColor] = existingColor
}

return adjustedAttributes
}
}

// MARK: - TextStorage: MediaAttachmentDelegate Methods
//
Expand Down
20 changes: 19 additions & 1 deletion Aztec/Classes/TextKit/TextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ open class TextView: UITextView {
HeaderFormatter(headerLevel: .h6),
FigureFormatter(),
FigcaptionFormatter(),
MarkFormatter(),
]

/// At some point moving ahead, this could be dynamically generated from the full list of registered formatters
Expand Down Expand Up @@ -1154,9 +1155,26 @@ open class TextView: UITextView {
///
/// - Parameter range: The NSRange to edit.
///
open func toggleMark(range: NSRange) {
open func toggleMark(range: NSRange, color: String?, resetColor: Bool) {
let formatter = MarkFormatter()
formatter.placeholderAttributes = self.defaultAttributes
formatter.defaultTextColor = self.defaultTextColor
formatter.textColor = color

// If the format exists remove the current formatting
// this can happen when the color changed.
if formatter.present(in: typingAttributes) {
typingAttributes = formatter.remove(from: typingAttributes)
let applicationRange = formatter.applicationRange(for: selectedRange, in: storage)
formatter.removeAttributes(from: storage, at: applicationRange)
typingAttributes = formatter.remove(from: typingAttributes)

// Reflect color changes by enabling the formatting again.
if !resetColor {
toggle(formatter: formatter, atRange: range)
}
return
}
toggle(formatter: formatter, atRange: range)
}

Expand Down
27 changes: 27 additions & 0 deletions AztecTests/TextKit/TextStorageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

0 comments on commit f6501a7

Please sign in to comment.