Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve Mark formatting #1352

Merged
merged 14 commits into from Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions Aztec.xcodeproj/project.pbxproj
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
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
@@ -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
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
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
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.
///
geriux marked this conversation as resolved.
Show resolved Hide resolved
/// - 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
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
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)
}
}