Skip to content

Commit f6501a7

Browse files
author
Gerardo Pacheco
authored
Merge pull request #1352 from wordpress-mobile/feature/mark-formatting-improvements
Improve Mark formatting
2 parents a42d2de + 7b9cc63 commit f6501a7

8 files changed

Lines changed: 189 additions & 12 deletions

File tree

Aztec.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
40A2986D1FD61B0C00AEDF3B /* ElementConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A2986C1FD61B0C00AEDF3B /* ElementConverter.swift */; };
1414
40A298711FD61B6F00AEDF3B /* ImageElementConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A298701FD61B6F00AEDF3B /* ImageElementConverter.swift */; };
1515
40A298731FD61E1900AEDF3B /* VideoElementConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A298721FD61E1900AEDF3B /* VideoElementConverter.swift */; };
16+
5608841E27DBA33600DA8AA7 /* MarkStringAttributeConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5608841D27DBA33600DA8AA7 /* MarkStringAttributeConverter.swift */; };
1617
568FF25827552BFF0057B2E3 /* MarkFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 568FF25727552BFF0057B2E3 /* MarkFormatter.swift */; };
1718
594C9D6F1D8BE61F00D74542 /* Aztec.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 5951CB8E1D8BC93600E1866F /* Aztec.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
1819
594C9D731D8BE6C300D74542 /* InAttributeConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59FEA06B1D8BDFA700D138DF /* InAttributeConverterTests.swift */; };
@@ -288,6 +289,7 @@
288289
40A298701FD61B6F00AEDF3B /* ImageElementConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageElementConverter.swift; sourceTree = "<group>"; };
289290
40A298721FD61E1900AEDF3B /* VideoElementConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoElementConverter.swift; sourceTree = "<group>"; };
290291
50A1CC6E250FEA93001D5517 /* LICENSE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = LICENSE.md; sourceTree = "<group>"; };
292+
5608841D27DBA33600DA8AA7 /* MarkStringAttributeConverter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MarkStringAttributeConverter.swift; sourceTree = "<group>"; };
291293
568FF25727552BFF0057B2E3 /* MarkFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkFormatter.swift; sourceTree = "<group>"; };
292294
5951CB8E1D8BC93600E1866F /* Aztec.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Aztec.framework; sourceTree = BUILT_PRODUCTS_DIR; };
293295
5951CB921D8BC93600E1866F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@@ -1101,6 +1103,7 @@
11011103
F15BA60C215159A600424120 /* ItalicStringAttributeConverter.swift */,
11021104
FF94935D245738AC0085ABB3 /* SuperscriptStringAttributeConverter.swift */,
11031105
FF949361245744090085ABB3 /* SubscriptStringAttributeConverter.swift */,
1106+
5608841D27DBA33600DA8AA7 /* MarkStringAttributeConverter.swift */,
11041107
F15BA60E21515C0F00424120 /* UnderlineStringAttributeConverter.swift */,
11051108
);
11061109
path = Implementations;
@@ -1547,6 +1550,7 @@
15471550
F1584794203C94AC00EE05A1 /* Dictionary+AttributedStringKey.swift in Sources */,
15481551
B572AC281E817CFE008948C2 /* CommentAttachment.swift in Sources */,
15491552
F1E1D5881FEC52EE0086B339 /* GenericElementConverter.swift in Sources */,
1553+
5608841E27DBA33600DA8AA7 /* MarkStringAttributeConverter.swift in Sources */,
15501554
F1FA0E861E6EF514009D98EE /* Node.swift in Sources */,
15511555
FFD3C1712344DB4E00AE8DA0 /* ForegroundColorCSSAttributeMatcher.swift in Sources */,
15521556
FFD3C1732344DCA900AE8DA0 /* ForegroundColorElementAttributeConverter.swift in Sources */,

Aztec/Classes/Converters/ElementsToAttributedString/Implementations/GenericElementConverter.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class GenericElementConverter: ElementConverter {
3636
lazy var liFormatter = LiFormatter()
3737
lazy var superscriptFormatter = SuperscriptFormatter()
3838
lazy var subscriptFormatter = SubscriptFormatter()
39+
lazy var markFormatter = MarkFormatter()
3940

4041
public lazy var elementFormattersMap: [Element: AttributeFormatter] = {
4142
return [
@@ -60,6 +61,7 @@ class GenericElementConverter: ElementConverter {
6061
.li: self.liFormatter,
6162
.sup: self.superscriptFormatter,
6263
.sub: self.subscriptFormatter,
64+
.mark: self.markFormatter,
6365
]
6466
}()
6567

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Foundation
2+
import UIKit
3+
4+
/// Converts the mark style information from string attributes and aggregates it into an
5+
/// existing array of element nodes.
6+
///
7+
open class MarkStringAttributeConverter: StringAttributeConverter {
8+
9+
private let toggler = HTMLStyleToggler(defaultElement: .mark, cssAttributeMatcher: ForegroundColorCSSAttributeMatcher())
10+
11+
public func convert(
12+
attributes: [NSAttributedString.Key: Any],
13+
andAggregateWith elementNodes: [ElementNode]) -> [ElementNode] {
14+
15+
var elementNodes = elementNodes
16+
17+
// We add the representation right away, if it exists... as it could contain attributes beyond just this
18+
// style. The enable and disable methods below can modify this as necessary.
19+
//
20+
21+
if let elementNode = attributes.storedElement(for: NSAttributedString.Key.markHtmlRepresentation) {
22+
let styleAttribute = elementNode.attributes.first(where: { $0.name == "style" })
23+
if let elementStyle = styleAttribute?.value.toString() {
24+
// Remove spaces between attribute name and value, and between style attributes.
25+
let styleAttributes = elementStyle.replacingOccurrences(of: ": ", with: ":").replacingOccurrences(of: "; ", with: ";")
26+
elementNode.attributes["style"] = .string(styleAttributes)
27+
}
28+
elementNodes.append(elementNode)
29+
}
30+
31+
if shouldEnableMarkElement(for: attributes) {
32+
return toggler.enable(in: elementNodes)
33+
} else {
34+
return toggler.disable(in: elementNodes)
35+
}
36+
}
37+
38+
// MARK: - Style Detection
39+
40+
func shouldEnableMarkElement(for attributes: [NSAttributedString.Key: Any]) -> Bool {
41+
return isMark(for: attributes)
42+
}
43+
44+
func isMark(for attributes: [NSAttributedString.Key: Any]) -> Bool {
45+
return attributes[.markHtmlRepresentation] != nil
46+
}
47+
}

Aztec/Classes/Formatters/Implementations/MarkFormatter.swift

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,43 @@ import UIKit
44
class MarkFormatter: AttributeFormatter {
55

66
var placeholderAttributes: [NSAttributedString.Key: Any]?
7+
var textColor: String?
8+
var defaultTextColor: UIColor?
79

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

1214
func apply(to attributes: [NSAttributedString.Key: Any], andStore representation: HTMLRepresentation?) -> [NSAttributedString.Key: Any] {
13-
var resultingAttributes = attributes
15+
var resultingAttributes = attributes
16+
let colorStyle = CSSAttribute(name: "color", value: textColor)
17+
let backgroundColorStyle = CSSAttribute(name: "background-color", value: "rgba(0, 0, 0, 0)")
18+
19+
let styleAttribute = Attribute(type: .style, value: .inlineCss([backgroundColorStyle, colorStyle]))
20+
let classAttribute = Attribute(type: .class, value: .string("has-inline-color"))
1421

15-
var representationToUse = HTMLRepresentation(for: .element(HTMLElementRepresentation.init(name: "mark", attributes: [])))
22+
// Setting the HTML representation
23+
var representationToUse = HTMLRepresentation(for: .element(HTMLElementRepresentation.init(name: "mark", attributes: [styleAttribute, classAttribute])))
1624
if let requestedRepresentation = representation {
1725
representationToUse = requestedRepresentation
1826
}
1927
resultingAttributes[.markHtmlRepresentation] = representationToUse
2028

29+
if let textColor = textColor {
30+
resultingAttributes[.foregroundColor] = UIColor(hexString: textColor)
31+
}
32+
2133
return resultingAttributes
2234
}
2335

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

27-
resultingAttributes.removeValue(forKey: .markHtmlRepresentation)
39+
if defaultTextColor != nil {
40+
resultingAttributes[.foregroundColor] = defaultTextColor
41+
}
2842

43+
resultingAttributes.removeValue(forKey: .markHtmlRepresentation)
2944
return resultingAttributes
3045
}
3146

Aztec/Classes/NSAttributedString/Conversions/AttributedStringParser.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class AttributedStringParser {
2929
UnderlineStringAttributeConverter(),
3030
SuperscriptStringAttributeConverter(),
3131
SubscriptStringAttributeConverter(),
32+
MarkStringAttributeConverter(),
3233
]
3334

3435
// MARK: - Attachment Converters

Aztec/Classes/TextKit/TextStorage.swift

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,11 @@ open class TextStorage: NSTextStorage {
143143

144144
// MARK: - NSAttributedString preprocessing
145145

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

150-
return preprocessedString
150+
return stringWithHeadings
151151
}
152152

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

256+
/// Preprocesses an attributed string that is missing a `markHtmlRepresentation` attribute for insertion in the storage.
257+
/// This method ensures that the `markHtmlRepresentation` attribute, if present in the current text storage,
258+
/// is applied to the new attributed string being inserted. This is particularly useful for maintaining
259+
/// mark formatting in scenarios like autocorrection or predictive text input.
260+
///
261+
/// - Important: This method adds the `markHtmlRepresentation` attribute to the new string if it's determined
262+
/// that the string should contain it, based on existing attributes in the text storage.
263+
/// This helps to overcome issues where autocorrected text does not carry over the `markHtmlRepresentation` attribute.
264+
///
265+
/// - Parameters:
266+
/// - attributedString: The new string to be inserted.
267+
/// - range: The range in the current text storage where the new string is to be inserted. This is used to determine
268+
/// if `markHtmlRepresentation` should be applied to the new string.
269+
///
270+
/// - Returns: The preprocessed attributed string with `markHtmlRepresentation` applied if necessary.
271+
///
272+
fileprivate func preprocessMarkForInsertion(_ attributedString: NSAttributedString, _ range: NSRange) -> NSAttributedString {
273+
let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
274+
275+
if range.location < textStore.length && range.length > 0 {
276+
let currentAttrs = textStore.attributes(at: range.location, effectiveRange: nil)
277+
278+
if let markAttribute = currentAttrs[.markHtmlRepresentation] {
279+
mutableAttributedString.addAttribute(.markHtmlRepresentation, value: markAttribute, range: NSRange(location: 0, length: mutableAttributedString.length))
280+
}
281+
}
282+
return mutableAttributedString
283+
}
284+
256285
fileprivate func detectAttachmentRemoved(in range: NSRange) {
257286
// Ref. https://github.com/wordpress-mobile/AztecEditor-iOS/issues/727:
258287
// If the delegate is not set, we *Explicitly* do not want to crash here.
@@ -304,14 +333,16 @@ open class TextStorage: NSTextStorage {
304333
}
305334

306335
override open func replaceCharacters(in range: NSRange, with attrString: NSAttributedString) {
307-
308-
let preprocessedString = preprocessAttributesForInsertion(attrString)
336+
let preprocessedString = preprocessAttributesForInsertion(attrString, range)
309337

310338
beginEditing()
311339

312340
detectAttachmentRemoved(in: range)
313-
textStore.replaceCharacters(in: range, with: preprocessedString)
314341

342+
// Apply mark formatting to the replacement string
343+
let markFormattedString = preprocessMarkForInsertion(preprocessedString, range)
344+
345+
textStore.replaceCharacters(in: range, with: markFormattedString)
315346
replaceTextStoreString(range, with: attrString.string)
316347

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

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

327-
textStore.setAttributes(fixedAttributes, range: range)
359+
// Adjust attributes for 'mark' formatting logic
360+
let adjustedAttributes = adjustAttributesForMark(fixedAttributes, range: range)
361+
362+
textStore.setAttributes(adjustedAttributes, range: range)
328363
edited(.editedAttributes, range: range, changeInLength: 0)
329-
364+
330365
endEditing()
331366
}
332367

@@ -482,6 +517,34 @@ private extension TextStorage {
482517
}
483518
}
484519

520+
// MARK: - Mark Formatting Attribute Fixes
521+
//
522+
private extension TextStorage {
523+
/// Adjusts text attributes to preserve the color of text marked with 'markHtmlRepresentation'.
524+
///
525+
/// This method checks if the specified range of text has the 'markHtmlRepresentation' attribute.
526+
/// If it does, the method retains the existing color attribute to preserve the 'mark' formatting.
527+
///
528+
/// - Parameters:
529+
/// - attrs: NSAttributedString attributes that are about to be applied.
530+
/// - range: Range of the text being modified.
531+
///
532+
/// - Returns: Adjusted collection of attributes, preserving color for 'mark' formatted text.
533+
///
534+
private func adjustAttributesForMark(_ attrs: [NSAttributedString.Key: Any], range: NSRange) -> [NSAttributedString.Key: Any] {
535+
var adjustedAttributes = attrs
536+
537+
// Check if the range has the 'markHtmlRepresentation' attribute
538+
let hasMarkAttribute = attribute(.markHtmlRepresentation, at: range.location, effectiveRange: nil) != nil
539+
540+
// If the 'markHtmlRepresentation' attribute is present, retain the existing color
541+
if hasMarkAttribute, let existingColor = textStore.attribute(.foregroundColor, at: range.location, effectiveRange: nil) as? UIColor {
542+
adjustedAttributes[.foregroundColor] = existingColor
543+
}
544+
545+
return adjustedAttributes
546+
}
547+
}
485548

486549
// MARK: - TextStorage: MediaAttachmentDelegate Methods
487550
//

Aztec/Classes/TextKit/TextView.swift

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ open class TextView: UITextView {
207207
HeaderFormatter(headerLevel: .h6),
208208
FigureFormatter(),
209209
FigcaptionFormatter(),
210+
MarkFormatter(),
210211
]
211212

212213
/// At some point moving ahead, this could be dynamically generated from the full list of registered formatters
@@ -1154,9 +1155,26 @@ open class TextView: UITextView {
11541155
///
11551156
/// - Parameter range: The NSRange to edit.
11561157
///
1157-
open func toggleMark(range: NSRange) {
1158+
open func toggleMark(range: NSRange, color: String?, resetColor: Bool) {
11581159
let formatter = MarkFormatter()
11591160
formatter.placeholderAttributes = self.defaultAttributes
1161+
formatter.defaultTextColor = self.defaultTextColor
1162+
formatter.textColor = color
1163+
1164+
// If the format exists remove the current formatting
1165+
// this can happen when the color changed.
1166+
if formatter.present(in: typingAttributes) {
1167+
typingAttributes = formatter.remove(from: typingAttributes)
1168+
let applicationRange = formatter.applicationRange(for: selectedRange, in: storage)
1169+
formatter.removeAttributes(from: storage, at: applicationRange)
1170+
typingAttributes = formatter.remove(from: typingAttributes)
1171+
1172+
// Reflect color changes by enabling the formatting again.
1173+
if !resetColor {
1174+
toggle(formatter: formatter, atRange: range)
1175+
}
1176+
return
1177+
}
11601178
toggle(formatter: formatter, atRange: range)
11611179
}
11621180

AztecTests/TextKit/TextStorageTests.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,4 +633,31 @@ class TextStorageTests: XCTestCase {
633633
XCTAssertEqual(storage.string, "Hello I'm a paragraph")
634634
XCTAssertNil(finalAttributes[.headingRepresentation])
635635
}
636+
637+
/// Verifies that missing Mark attributes are retained on string replacements when appropriate
638+
///
639+
func testMissingMarkAttributeIsRetained() {
640+
let formatter = MarkFormatter()
641+
storage.replaceCharacters(in: storage.rangeOfEntireString, with: "Hello i'm a text highlighted")
642+
formatter.applyAttributes(to: storage, at: storage.rangeOfEntireString)
643+
644+
let originalAttributes = storage.attributes(at: 0, effectiveRange: nil)
645+
XCTAssertEqual(storage.string, "Hello i'm a text highlighted")
646+
XCTAssertEqual(originalAttributes.count, 2)
647+
XCTAssertNotNil(originalAttributes[.markHtmlRepresentation])
648+
649+
let autoCorrectedAttributes = originalAttributes.filter { $0.key != .markHtmlRepresentation }
650+
651+
let autoCorrectedString = NSAttributedString(
652+
string: "I'm",
653+
attributes: autoCorrectedAttributes
654+
)
655+
656+
let range = NSRange(location: 6, length: 3)
657+
storage.replaceCharacters(in: range, with: autoCorrectedString)
658+
659+
let finalAttributes = storage.attributes(at: range.location, effectiveRange: nil)
660+
XCTAssertEqual(storage.string, "Hello I'm a text highlighted")
661+
XCTAssertEqual(originalAttributes.keys, finalAttributes.keys)
662+
}
636663
}

0 commit comments

Comments
 (0)