From 1e049977f94625e45a91259fb4c17a569f317e52 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Mon, 17 Apr 2017 11:27:03 -0300 Subject: [PATCH 01/23] LayoutManager: Minor cleanup --- Aztec/Classes/TextKit/LayoutManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Aztec/Classes/TextKit/LayoutManager.swift b/Aztec/Classes/TextKit/LayoutManager.swift index 8183709a5..cbda6ae91 100644 --- a/Aztec/Classes/TextKit/LayoutManager.swift +++ b/Aztec/Classes/TextKit/LayoutManager.swift @@ -9,7 +9,7 @@ class LayoutManager: NSLayoutManager { /// Blockquote's Left Border Color /// - var blockquoteBorderColor: UIColor = UIColor(red: 0.52, green: 0.65, blue: 0.73, alpha: 1.0) + var blockquoteBorderColor = UIColor(red: 0.52, green: 0.65, blue: 0.73, alpha: 1.0) /// Blockquote's Background Color /// From c2cb0d3ba409671750a8c3c0699f311765672a20 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Mon, 17 Apr 2017 11:27:25 -0300 Subject: [PATCH 02/23] TextListFormatter: Adds comments --- .../Formatters/TextListFormatter.swift | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/Aztec/Classes/Formatters/TextListFormatter.swift b/Aztec/Classes/Formatters/TextListFormatter.swift index 8fed9069b..835e3ef5b 100644 --- a/Aztec/Classes/Formatters/TextListFormatter.swift +++ b/Aztec/Classes/Formatters/TextListFormatter.swift @@ -1,53 +1,75 @@ import Foundation import UIKit + +// MARK: - Lists Formatter +// class TextListFormatter: ParagraphAttributeFormatter { - + + /// Style of the list + /// let listStyle: TextList.Style + + /// Attributes to be added by default + /// let placeholderAttributes: [String : Any]? + + /// Designated Initializer + /// init(style: TextList.Style, placeholderAttributes: [String : Any]? = nil) { self.listStyle = style self.placeholderAttributes = placeholderAttributes } + + // MARK: - Overwriten Methods + func apply(to attributes: [String : Any]) -> [String: Any] { - var resultingAttributes = attributes let newParagraphStyle = ParagraphStyle() if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? NSParagraphStyle { newParagraphStyle.setParagraphStyle(paragraphStyle) } + if newParagraphStyle.textList == nil { newParagraphStyle.headIndent += Metrics.listTextIndentation newParagraphStyle.firstLineHeadIndent += Metrics.listTextIndentation } + newParagraphStyle.textList = TextList(style: self.listStyle) + + var resultingAttributes = attributes resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle + return resultingAttributes } - func remove(from attributes:[String: Any]) -> [String: Any] { - var resultingAttributes = attributes + func remove(from attributes: [String: Any]) -> [String: Any] { + guard present(in: attributes) else { + return attributes + } + let newParagraphStyle = ParagraphStyle() - guard let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, - paragraphStyle.textList?.style == self.listStyle - else { - return resultingAttributes + if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? NSParagraphStyle { + newParagraphStyle.setParagraphStyle(paragraphStyle) } - newParagraphStyle.setParagraphStyle(paragraphStyle) newParagraphStyle.headIndent -= Metrics.listTextIndentation newParagraphStyle.firstLineHeadIndent -= Metrics.listTextIndentation newParagraphStyle.textList = nil + + var resultingAttributes = attributes resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle + return resultingAttributes } func present(in attributes: [String : Any]) -> Bool { - guard let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, - let textList = paragraphStyle.textList else { + guard let style = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, let list = style.textList else { return false } - return textList.style == listStyle + + return list.style == listStyle + } } } From a7a620a3de0fca3a7ef3847393f1f4c641d68855 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Mon, 17 Apr 2017 11:27:35 -0300 Subject: [PATCH 03/23] TextListFormatter: Disabling empty line placeholders --- Aztec/Classes/Formatters/TextListFormatter.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Aztec/Classes/Formatters/TextListFormatter.swift b/Aztec/Classes/Formatters/TextListFormatter.swift index 835e3ef5b..ce6f2ffce 100644 --- a/Aztec/Classes/Formatters/TextListFormatter.swift +++ b/Aztec/Classes/Formatters/TextListFormatter.swift @@ -70,6 +70,9 @@ class TextListFormatter: ParagraphAttributeFormatter { return list.style == listStyle } + + func needsEmptyLinePlaceholder() -> Bool { + return false } } From 9a99ac055583e21febc866f6732209cae1d6215b Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Mon, 17 Apr 2017 12:54:06 -0300 Subject: [PATCH 04/23] LayoutManager: Removes extraLineFragment Workaround for lists --- Aztec/Classes/TextKit/LayoutManager.swift | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/Aztec/Classes/TextKit/LayoutManager.swift b/Aztec/Classes/TextKit/LayoutManager.swift index cbda6ae91..7f770620c 100644 --- a/Aztec/Classes/TextKit/LayoutManager.swift +++ b/Aztec/Classes/TextKit/LayoutManager.swift @@ -108,17 +108,6 @@ private extension LayoutManager { self.drawItem(number: markerNumber, in: lineRect, from: list, using: paragraphStyle, at: location) } - - // Draw the Last Line's Item - guard range.endLocation == textStorage.rangeOfEntireString.endLocation, !extraLineFragmentRect.isEmpty else { - return - } - - let location = range.endLocation - 1 - let lineRect = extraLineFragmentRect.offsetBy(dx: origin.x, dy: origin.y) - let markerNumber = textStorage.itemNumber(in: list, at: location) + 1 - - drawItem(number: markerNumber, in: lineRect, from: list, using: paragraphStyle, at: location) } } From a47ebb37e8685fb5ce576de17d5be64089bb1834 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Mon, 17 Apr 2017 12:54:24 -0300 Subject: [PATCH 05/23] TextView: Hack hack --- Aztec/Classes/TextKit/TextView.swift | 29 +++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 1b968cf7e..1ba4c10b9 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -497,7 +497,7 @@ open class TextView: UITextView { // MARK: - Formatting func toggle(formatter: AttributeFormatter, atRange range: NSRange) { - let applicationRange = storage.toggle(formatter: formatter, at: range) + let applicationRange = storage.toggle(formatter: formatter, at: range) if applicationRange.length == 0 { typingAttributes = formatter.toggle(in: typingAttributes) } else { @@ -568,20 +568,43 @@ open class TextView: UITextView { /// - Parameter range: The NSRange to edit. /// open func toggleOrderedList(range: NSRange) { + insertText("\n") + selectedRange = NSRange(location: 0, length: 0) + let formatter = TextListFormatter(style: .ordered, placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) - forceRedrawCursorAfterDelay() + +// forceRedrawCursorAfterDelay() } + override open var typingAttributes: [String : Any] { + get { + NSLog("### typingAttributes Getter") + if selectedRange.location == storage.length && selectedRange.length == 0 { + var updated = super.typingAttributes + updated.removeValue(forKey: NSParagraphStyleAttributeName) + + return updated + } + + return super.typingAttributes + } + set { + super.typingAttributes = newValue + } + } /// Adds or removes a unordered list style from the specified range. /// /// - Parameter range: The NSRange to edit. /// open func toggleUnorderedList(range: NSRange) { + insertText("\n") + selectedRange = NSRange(location: 0, length: 0) + let formatter = TextListFormatter(style: .unordered, placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) - forceRedrawCursorAfterDelay() +// forceRedrawCursorAfterDelay() } From 63eee3eec14784fe155e02eba04bd7e7a3de9959 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Mon, 17 Apr 2017 16:17:10 -0300 Subject: [PATCH 06/23] AttributeFormatter: Style Updates --- Aztec/Classes/Formatters/AttributeFormatter.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Aztec/Classes/Formatters/AttributeFormatter.swift b/Aztec/Classes/Formatters/AttributeFormatter.swift index ce818537c..8bde432fa 100644 --- a/Aztec/Classes/Formatters/AttributeFormatter.swift +++ b/Aztec/Classes/Formatters/AttributeFormatter.swift @@ -105,7 +105,8 @@ extension AttributeFormatter { return result && enumerateAtLeastOnce } - @discardableResult func toggle(in attributes: [String: Any]) -> [String: Any] { + @discardableResult + func toggle(in attributes: [String: Any]) -> [String: Any] { if present(in: attributes) { return remove(from: attributes) } else { @@ -117,7 +118,8 @@ extension AttributeFormatter { /// /// - Returns: the full range where the attributes where applied /// - @discardableResult func applyAttributes(to text: NSMutableAttributedString, at range: NSRange) -> NSRange { + @discardableResult + func applyAttributes(to text: NSMutableAttributedString, at range: NSRange) -> NSRange { var rangeToApply = applicationRange(for: range, in: text) if needsEmptyLinePlaceholder() && worksInEmptyRange() && ( rangeToApply.length == 0 || text.length == 0) { @@ -139,7 +141,8 @@ extension AttributeFormatter { /// /// - Returns: the full range where the attributes where removed /// - @discardableResult func removeAttributes(from text: NSMutableAttributedString, at range: NSRange) -> NSRange { + @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) From 2b6fae0a9740cf456e7c2d819daa725b056e2fe4 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Mon, 17 Apr 2017 16:17:44 -0300 Subject: [PATCH 07/23] TextView: Re-Enables Redraw Workaround --- Aztec/Classes/TextKit/TextView.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 1ba4c10b9..6ce64c672 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -168,7 +168,7 @@ open class TextView: UITextView { storage.undoManager = undoManager commonInit() } - + required public init?(coder aDecoder: NSCoder) { defaultFont = UIFont.systemFont(ofSize: 14) @@ -573,8 +573,7 @@ open class TextView: UITextView { let formatter = TextListFormatter(style: .ordered, placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) - -// forceRedrawCursorAfterDelay() + forceRedrawCursorAfterDelay() } override open var typingAttributes: [String : Any] { @@ -604,7 +603,7 @@ open class TextView: UITextView { let formatter = TextListFormatter(style: .unordered, placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) -// forceRedrawCursorAfterDelay() + forceRedrawCursorAfterDelay() } From 6e01e48cb34aa57c6548a78f18b4b2ff34828cff Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Mon, 17 Apr 2017 17:33:17 -0300 Subject: [PATCH 08/23] TextView: Overwritting Typing Attributes --- Aztec/Classes/TextKit/TextView.swift | 44 ++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 6ce64c672..2d8889417 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -576,23 +576,24 @@ open class TextView: UITextView { forceRedrawCursorAfterDelay() } - override open var typingAttributes: [String : Any] { - get { - NSLog("### typingAttributes Getter") - if selectedRange.location == storage.length && selectedRange.length == 0 { - var updated = super.typingAttributes - updated.removeValue(forKey: NSParagraphStyleAttributeName) - return updated - } + /// Overwrites Typing Attributes: + /// This is the (only) valid hook we've found, in order to (selectively) remove the List attributes. + /// For details, see: https://github.com/wordpress-mobile/AztecEditor-iOS/issues/414 + /// + override open var typingAttributes: [String: Any] { + get { + let updatedAttributes = ensureRemovalOfListAttribute(from: super.typingAttributes) + super.typingAttributes = updatedAttributes - return super.typingAttributes + return updatedAttributes } set { super.typingAttributes = newValue } } + /// Adds or removes a unordered list style from the specified range. /// /// - Parameter range: The NSRange to edit. @@ -746,6 +747,31 @@ open class TextView: UITextView { } + /// Removes the List Attributes from a collection of attributes, whenever: + /// + /// A. The caret is at the very end of the document (+ there is no selected text) + /// B. There's a list! + /// + /// - Parameter attributes: Typing Attributes. + /// + /// - Returns: Updated Typing Attributes. + /// + private func ensureRemovalOfListAttribute(from attributes: [String: Any]) -> [String: Any] { + guard selectedRange.location == storage.length && selectedRange.length == 0 else { + return attributes + } + + guard let style = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, style.textList != nil else { + return attributes + } + + var updatedAttributes = attributes + updatedAttributes[NSParagraphStyleAttributeName] = ParagraphStyle.default + + return updatedAttributes + } + + /// Indicates whether a new empty paragraph was created after the insertion of text at the specified location /// /// - Parameters: From a5d544ce548210f2e455ccdb377d5e76f84837ef Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Mon, 17 Apr 2017 17:39:04 -0300 Subject: [PATCH 09/23] TextView: Cleanup --- Aztec/Classes/TextKit/TextView.swift | 64 ++++++++++++++++++---------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 2d8889417..fa5bf3ebe 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -211,6 +211,26 @@ open class TextView: UITextView { addGestureRecognizer(attachmentGestureRecognizer) } + + // MARK: - Overwritten Properties + + /// Overwrites Typing Attributes: + /// This is the (only) valid hook we've found, in order to (selectively) remove the List attributes. + /// For details, see: https://github.com/wordpress-mobile/AztecEditor-iOS/issues/414 + /// + override open var typingAttributes: [String: Any] { + get { + let updatedAttributes = ensureRemovalOfListAttribute(from: super.typingAttributes) + super.typingAttributes = updatedAttributes + + return updatedAttributes + } + set { + super.typingAttributes = newValue + } + } + + // MARK: - Intercept copy paste operations open override func cut(_ sender: Any?) { @@ -568,29 +588,12 @@ open class TextView: UITextView { /// - Parameter range: The NSRange to edit. /// open func toggleOrderedList(range: NSRange) { - insertText("\n") - selectedRange = NSRange(location: 0, length: 0) + ensureInsertionOfNewlineOnEmptyDocuments() let formatter = TextListFormatter(style: .ordered, placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) - forceRedrawCursorAfterDelay() - } - - - /// Overwrites Typing Attributes: - /// This is the (only) valid hook we've found, in order to (selectively) remove the List attributes. - /// For details, see: https://github.com/wordpress-mobile/AztecEditor-iOS/issues/414 - /// - override open var typingAttributes: [String: Any] { - get { - let updatedAttributes = ensureRemovalOfListAttribute(from: super.typingAttributes) - super.typingAttributes = updatedAttributes - return updatedAttributes - } - set { - super.typingAttributes = newValue - } + forceRedrawCursorAfterDelay() } @@ -599,11 +602,11 @@ open class TextView: UITextView { /// - Parameter range: The NSRange to edit. /// open func toggleUnorderedList(range: NSRange) { - insertText("\n") - selectedRange = NSRange(location: 0, length: 0) + ensureInsertionOfNewlineOnEmptyDocuments() let formatter = TextListFormatter(style: .unordered, placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) + forceRedrawCursorAfterDelay() } @@ -637,7 +640,7 @@ open class TextView: UITextView { private lazy var paragraphFormatters: [AttributeFormatter] = [ TextListFormatter(style: .ordered), TextListFormatter(style: .unordered), - BlockquoteFormatter(), + BlockquoteFormatter(), HeaderFormatter(headerLevel:.h1), HeaderFormatter(headerLevel:.h2), HeaderFormatter(headerLevel:.h3), @@ -772,6 +775,23 @@ open class TextView: UITextView { } + /// Inserts an empty line, whenever we're at the end of the document, and there's no selected text. + /// + private func ensureInsertionOfNewlineOnEmptyDocuments() { + guard selectedRange.location == storage.length && selectedRange.length == 0 else { + return + } + + let previousRange = selectedRange + let previousStyle = typingAttributes + + insertText(String(.newline)) + + selectedRange = previousRange + typingAttributes = previousStyle + } + + /// Indicates whether a new empty paragraph was created after the insertion of text at the specified location /// /// - Parameters: From 03066ff3d2c8bf1377959dd92a25c2b8eb851e46 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Mon, 17 Apr 2017 17:45:17 -0300 Subject: [PATCH 10/23] TextListFormatter: Revers faulty update --- Aztec/Classes/Formatters/TextListFormatter.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Aztec/Classes/Formatters/TextListFormatter.swift b/Aztec/Classes/Formatters/TextListFormatter.swift index ce6f2ffce..ac94e426c 100644 --- a/Aztec/Classes/Formatters/TextListFormatter.swift +++ b/Aztec/Classes/Formatters/TextListFormatter.swift @@ -45,14 +45,14 @@ class TextListFormatter: ParagraphAttributeFormatter { } func remove(from attributes: [String: Any]) -> [String: Any] { - guard present(in: attributes) else { + guard let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, + paragraphStyle.textList?.style == self.listStyle + else { return attributes } let newParagraphStyle = ParagraphStyle() - if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? NSParagraphStyle { - newParagraphStyle.setParagraphStyle(paragraphStyle) - } + newParagraphStyle.setParagraphStyle(paragraphStyle) newParagraphStyle.headIndent -= Metrics.listTextIndentation newParagraphStyle.firstLineHeadIndent -= Metrics.listTextIndentation newParagraphStyle.textList = nil From 463dc8807637685f2b09dc3c91f437d91d442a88 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Tue, 18 Apr 2017 11:26:56 -0300 Subject: [PATCH 11/23] TextView: Updates refreshStylesAfterDeletion --- Aztec/Classes/TextKit/TextView.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index fa5bf3ebe..a7c1b5893 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -638,8 +638,6 @@ open class TextView: UITextView { }() private lazy var paragraphFormatters: [AttributeFormatter] = [ - TextListFormatter(style: .ordered), - TextListFormatter(style: .unordered), BlockquoteFormatter(), HeaderFormatter(headerLevel:.h1), HeaderFormatter(headerLevel:.h2), @@ -661,14 +659,13 @@ open class TextView: UITextView { guard deletedText.string == String(.newline) || range.location == 0 else { return } + for formatter in paragraphFormatters { if let locationBefore = storage.string.location(before: range.location), formatter.present(in: textStorage, at: locationBefore) { if range.endLocation < storage.length { formatter.applyAttributes(to: storage, at: range) } - } else if formatter.present(in: textStorage, at: range.location) || range.location == 0 { - formatter.removeAttributes(from: textStorage, at: range) } } } From 57f1aecb5c5dd9e17c7c3685d35a63aca008e403 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Tue, 18 Apr 2017 11:27:07 -0300 Subject: [PATCH 12/23] TextStorage: Updates Style --- Aztec/Classes/TextKit/TextStorage.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index dcd7d0e70..d90b4c473 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -713,7 +713,8 @@ 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 From 39176ef266ca6adcc1923f34d2ff4cae7d1badc3 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Tue, 18 Apr 2017 13:06:57 -0300 Subject: [PATCH 13/23] TextView: New Unit Tests --- AztecTests/TextViewTests.swift | 64 ++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/AztecTests/TextViewTests.swift b/AztecTests/TextViewTests.swift index cffd40eb0..9215e4ae3 100644 --- a/AztecTests/TextViewTests.swift +++ b/AztecTests/TextViewTests.swift @@ -679,4 +679,68 @@ class AztecVisualTextViewTests: XCTestCase { XCTAssertEqual(textView.getHTML(), "

Header

1") } + + /// Verifies that New Line Characters get effectively inserted after a Text List. + /// + /// Input: + /// - Ordered List + /// - \n at the end of the document + /// + /// Ref. https://github.com/wordpress-mobile/AztecEditor-iOS/pull/425 + /// + func testNewLinesAreInsertedAfterEmptyList() { + let newline = String(.newline) + let textView = createTextView(withHTML: "") + + // Toggle List + Move the selection to the EOD + textView.toggleOrderedList(range: .zero) + + var expectedLength = textView.text.characters.count + textView.selectedRange = NSRange(location: expectedLength, length: 0) + + // Insert Newline + textView.insertText(newline) + expectedLength += newline.characters.count + + XCTAssertEqual(textView.text.characters.count, expectedLength) + } + + /// Verifies that New List Items do get their bulet, even when the ending `\n` character was deleted. + /// + /// Input: + /// - Ordered List + /// - Text: "First Item" + /// - Selection of the `\n` at the EOD, and backspace + /// - Text: "\nSecond Item" + /// + /// Ref. https://github.com/wordpress-mobile/AztecEditor-iOS/pull/425 + /// + func testNewLinesGetBulletStyleEvenAfterDeletingEndOfDocumentNewline() { + let firstItemText = "First Item" + let secondItemText = "Second Item" + let newline = String(.newline) + + let textView = createTextView(withHTML: "") + + textView.toggleOrderedList(range: .zero) + + textView.insertText(firstItemText) + + // Select the end of the document + let length = textView.text.characters.count + textView.selectedRange = NSRange(location: length, length: 0) + + // Delete + Insert Newline + textView.deleteBackward() + textView.insertText(newline + secondItemText) + + // Verify it's still present + let secondLineIndex = firstItemText.characters.count + newline.characters.count + let secondLineRange = NSRange(location: secondLineIndex, length: secondItemText.characters.count) + + let formatter = TextListFormatter(style: .ordered) + let present = formatter.present(in: textView.storage, at: secondLineRange) + + XCTAssert(present) + } } From 08b995152bfe1f36133487f587f03ea1eb5a6ec0 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Tue, 18 Apr 2017 13:10:06 -0300 Subject: [PATCH 14/23] TextView: Fixing newline behavior --- Aztec/Classes/TextKit/TextView.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index a7c1b5893..f2df6879b 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -735,9 +735,21 @@ open class TextView: UITextView { return false } - let formatters:[AttributeFormatter] = [TextListFormatter(style: .ordered), TextListFormatter(style: .unordered), BlockquoteFormatter()] + let formatters:[AttributeFormatter] = [ + TextListFormatter(style: .ordered), + TextListFormatter(style: .unordered), + BlockquoteFormatter() + ] + + let atEdgeOfDocument = range.location >= storage.length + for formatter in formatters { - if formatter.present(in: textStorage, at: range.location) { + if atEdgeOfDocument && formatter.present(in: typingAttributes) { + typingAttributes = formatter.remove(from: typingAttributes) + return true + } + + if !atEdgeOfDocument && formatter.present(in: textStorage, at: range.location) { formatter.removeAttributes(from: textStorage, at: range) return true } From af9a78f3af9b0c1171aa32105d5dafa2fc2a109c Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Tue, 18 Apr 2017 14:54:41 -0300 Subject: [PATCH 15/23] TextView: Updates ensureRemovalOfListAttribute Logic --- Aztec/Classes/TextKit/TextView.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index f2df6879b..ea6faab2e 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -763,6 +763,7 @@ open class TextView: UITextView { /// /// A. The caret is at the very end of the document (+ there is no selected text) /// B. There's a list! + /// C. The previous character is a '\n' /// /// - Parameter attributes: Typing Attributes. /// @@ -777,6 +778,16 @@ open class TextView: UITextView { return attributes } + let previousRange = NSRange(location: selectedRange.location - 1, length: 1) + guard previousRange.endLocation <= storage.length else { + return attributes + } + + let previousString = storage.attributedSubstring(from: previousRange).string + guard previousString == String(.newline) else { + return attributes + } + var updatedAttributes = attributes updatedAttributes[NSParagraphStyleAttributeName] = ParagraphStyle.default From f33abeafab37c47f3cfcce2fdca985a727c10211 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Tue, 18 Apr 2017 14:55:40 -0300 Subject: [PATCH 16/23] TextView: Updates Newline Insertion Logic --- Aztec/Classes/TextKit/TextView.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index ea6faab2e..1750a6eba 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -802,13 +802,14 @@ open class TextView: UITextView { return } + // Note: We *really* need to use super, so that we prevent recursive loops. let previousRange = selectedRange - let previousStyle = typingAttributes + let previousStyle = super.typingAttributes - insertText(String(.newline)) + super.insertText(String(.newline)) selectedRange = previousRange - typingAttributes = previousStyle + super.typingAttributes = previousStyle } From 5b148d4b33f34812e2d47f35e7cc0dbe7b8c6a0b Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Tue, 18 Apr 2017 14:59:48 -0300 Subject: [PATCH 17/23] TextView: Wiring List Formatter Check --- Aztec/Classes/Formatters/TextListFormatter.swift | 5 +++++ Aztec/Classes/TextKit/TextView.swift | 10 +++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Aztec/Classes/Formatters/TextListFormatter.swift b/Aztec/Classes/Formatters/TextListFormatter.swift index ac94e426c..2c803706b 100644 --- a/Aztec/Classes/Formatters/TextListFormatter.swift +++ b/Aztec/Classes/Formatters/TextListFormatter.swift @@ -74,5 +74,10 @@ class TextListFormatter: ParagraphAttributeFormatter { func needsEmptyLinePlaceholder() -> Bool { return false } + + static func listsOfAnyKindPresent(in attributes: [String: Any]) -> Bool { + let style = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle + return style?.textList != nil + } } diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 1750a6eba..561165944 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -287,6 +287,14 @@ open class TextView: UITextView { // MARK: - Intercept keyboard operations open override func insertText(_ text: String) { + + /// Insert `\n` characters whenever we're in a Text List, the user presses \n, and we're literally + /// at the End of the Document. + /// + if TextListFormatter.listsOfAnyKindPresent(in: typingAttributes) { + ensureInsertionOfNewlineOnEmptyDocuments() + } + // Note: // Whenever the entered text causes the Paragraph Attributes to be removed, we should prevent the actual // text insertion to happen. Thus, we won't call super.insertText. @@ -774,7 +782,7 @@ open class TextView: UITextView { return attributes } - guard let style = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, style.textList != nil else { + guard TextListFormatter.listsOfAnyKindPresent(in: attributes) else { return attributes } From 9ba0490617c312aabac050ebf66dda1b1aad8ec9 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Tue, 18 Apr 2017 15:11:14 -0300 Subject: [PATCH 18/23] TextViewTests: New Unit Tests --- AztecTests/TextViewTests.swift | 54 ++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/AztecTests/TextViewTests.swift b/AztecTests/TextViewTests.swift index 9215e4ae3..2e85b6d8c 100644 --- a/AztecTests/TextViewTests.swift +++ b/AztecTests/TextViewTests.swift @@ -680,13 +680,63 @@ class AztecVisualTextViewTests: XCTestCase { XCTAssertEqual(textView.getHTML(), "

Header

1") } + + /// Verifies that a Text List does not get removed, whenever the user presses backspace + /// + /// Input: + /// - Ordered List + /// - "First Item" + /// - Backspace + /// + /// Ref. Scenario Mark I on Issue https://github.com/wordpress-mobile/AztecEditor-iOS/pull/425 + /// + func testListDoesNotGetLostAfterPressingBackspace() { + let textView = createTextView(withHTML: "") + + + textView.toggleOrderedList(range: .zero) + textView.insertText("First Item") + textView.deleteBackward() + + let formatter = TextListFormatter(style: .ordered) + let range = textView.storage.rangeOfEntireString + let present = formatter.present(in: textView.storage, at: range) + + XCTAssertTrue(present) + } + + + /// Verifies that the Text List does get nuked whenever the only `\n` present in the document is deleted. + /// + /// Input: + /// - Ordered List + /// - Selection of the EOD + /// - Backspace + /// + /// Ref. Scenario Mark II on Issue https://github.com/wordpress-mobile/AztecEditor-iOS/pull/425 + /// + func testEmptyListGetsNukedWheneverTheOnlyNewlineCharacterInTheDocumentIsNuked() { + let textView = createTextView(withHTML: "") + + textView.toggleOrderedList(range: .zero) + + let length = textView.storage.length + textView.selectedRange = NSRange(location: length, length: 0) + + textView.deleteBackward() + + XCTAssertFalse(TextListFormatter.listsOfAnyKindPresent(in: textView.typingAttributes)) + XCTAssert(textView.storage.length == 0) + } + + /// Verifies that New Line Characters get effectively inserted after a Text List. /// /// Input: /// - Ordered List /// - \n at the end of the document /// - /// Ref. https://github.com/wordpress-mobile/AztecEditor-iOS/pull/425 + /// Ref. Scenario Mark III on Issue https://github.com/wordpress-mobile/AztecEditor-iOS/pull/425 /// func testNewLinesAreInsertedAfterEmptyList() { let newline = String(.newline) @@ -713,7 +763,7 @@ class AztecVisualTextViewTests: XCTestCase { /// - Selection of the `\n` at the EOD, and backspace /// - Text: "\nSecond Item" /// - /// Ref. https://github.com/wordpress-mobile/AztecEditor-iOS/pull/425 + /// Ref. Scenario Mark IV on Issue https://github.com/wordpress-mobile/AztecEditor-iOS/pull/425 /// func testNewLinesGetBulletStyleEvenAfterDeletingEndOfDocumentNewline() { let firstItemText = "First Item" From f2e0f475dcb76d827619ef31e25feec6d2b1c33f Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Tue, 18 Apr 2017 15:41:11 -0300 Subject: [PATCH 19/23] Implements new NSAttributedString Helper --- .../NSAttributedString+Analyzers.swift | 11 +++++++++++ .../NSAttributedStringAnalyzerTests.swift | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/Aztec/Classes/Extensions/NSAttributedString+Analyzers.swift b/Aztec/Classes/Extensions/NSAttributedString+Analyzers.swift index 2b936206b..d9351f6e6 100644 --- a/Aztec/Classes/Extensions/NSAttributedString+Analyzers.swift +++ b/Aztec/Classes/Extensions/NSAttributedString+Analyzers.swift @@ -27,4 +27,15 @@ extension NSAttributedString { return attribute(NSLinkAttributeName, at: afterRange.location, effectiveRange: nil) != nil } + + /// Returns the Substring at the specified range, whenever the received range is valid, or nil + /// otherwise. + /// + func safeSubstring(at range: NSRange) -> String? { + guard range.location >= 0 && range.endLocation <= length else { + return nil + } + + return attributedSubstring(from: range).string + } } diff --git a/AztecTests/Extensions/NSAttributedStringAnalyzerTests.swift b/AztecTests/Extensions/NSAttributedStringAnalyzerTests.swift index b48f3eed5..0e6712b29 100644 --- a/AztecTests/Extensions/NSAttributedStringAnalyzerTests.swift +++ b/AztecTests/Extensions/NSAttributedStringAnalyzerTests.swift @@ -106,4 +106,21 @@ class NSAttributedStringAnalyzerTests: XCTestCase { XCTAssertTrue(fullString.isLocationSuccededByLink(i)) } } + + /// Verifies that *safeSubstring* returns nil, whenever the range parameter is not valid within the receiver. + /// + func testSafeSubstringAtRangeReturnsNilWhenQueriedWithOutOfBoundsRanges() { + let fullString = LinkedSample.fullString + let empty = NSAttributedString() + + let ranges = [ + NSRange(location: fullString.length, length: 1), + NSRange(location: -1, length: 1) + ] + + for range in ranges { + XCTAssertNil(fullString.safeSubstring(at: range)) + XCTAssertNil(empty.safeSubstring(at: range)) + } + } } From 6e5122640f210cc27c2e9c1e81804e9148faf950 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Tue, 18 Apr 2017 15:41:22 -0300 Subject: [PATCH 20/23] TextView: New Unit Test --- AztecTests/TextViewTests.swift | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/AztecTests/TextViewTests.swift b/AztecTests/TextViewTests.swift index 2e85b6d8c..36068f4fb 100644 --- a/AztecTests/TextViewTests.swift +++ b/AztecTests/TextViewTests.swift @@ -681,6 +681,8 @@ class AztecVisualTextViewTests: XCTestCase { } + // MARK: - Lists + /// Verifies that a Text List does not get removed, whenever the user presses backspace /// /// Input: @@ -793,4 +795,24 @@ class AztecVisualTextViewTests: XCTestCase { XCTAssert(present) } + + /// Verifies that after selecting a newline below a TextList does, TextView wil not render (nor carry over) + /// the Text List formatting attributes. + /// + /// Input: + /// - Ordered List + /// - Selection of the `\n` at the EOD + /// + /// Ref. Scenario Mark V on Issue https://github.com/wordpress-mobile/AztecEditor-iOS/pull/425 + /// + func testTypingAttributesLooseTextListWhenSelectingAnEmptyNewlineBelowTextList() { + let textView = createTextView(withHTML: "") + + textView.toggleOrderedList(range: .zero) + + let length = textView.text.characters.count + textView.selectedRange = NSRange(location: length, length: 0) + + XCTAssertFalse(TextListFormatter.listsOfAnyKindPresent(in: textView.typingAttributes)) + } } From d4755a9d6080b8db855358ff396bbe994c1a4312 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Tue, 18 Apr 2017 15:41:36 -0300 Subject: [PATCH 21/23] TextView: Fixing unit test --- Aztec/Classes/TextKit/TextView.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 561165944..8c154aee6 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -787,11 +787,7 @@ open class TextView: UITextView { } let previousRange = NSRange(location: selectedRange.location - 1, length: 1) - guard previousRange.endLocation <= storage.length else { - return attributes - } - - let previousString = storage.attributedSubstring(from: previousRange).string + let previousString = storage.safeSubstring(at: previousRange) ?? String(.newline) guard previousString == String(.newline) else { return attributes } From c98217d0f9b81182d00779a05494e4dab4a99c86 Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Tue, 18 Apr 2017 15:53:51 -0300 Subject: [PATCH 22/23] TextView: New Unit Test --- AztecTests/TextViewTests.swift | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/AztecTests/TextViewTests.swift b/AztecTests/TextViewTests.swift index 36068f4fb..a59c77a31 100644 --- a/AztecTests/TextViewTests.swift +++ b/AztecTests/TextViewTests.swift @@ -815,4 +815,28 @@ class AztecVisualTextViewTests: XCTestCase { XCTAssertFalse(TextListFormatter.listsOfAnyKindPresent(in: textView.typingAttributes)) } + + /// Verifies that a Text List gets removed, whenever the user types `\n` in an empty line. + /// + /// Input: + /// - Ordered List + /// - `\n` on the first line + /// + /// Ref. Scenario Mark IV on Issue https://github.com/wordpress-mobile/AztecEditor-iOS/pull/425 + /// + func testListGetsRemovedWhenTypingNewLineOnAnEmptyBullet() { + let textView = createTextView(withHTML: "") + + textView.toggleOrderedList(range: .zero) + textView.insertText("\n") + + let formatter = TextListFormatter(style: .ordered) + let attributedText = textView.attributedText! + + for location in 0 ..< attributedText.length { + XCTAssertFalse(formatter.present(in: attributedText, at: location)) + } + + XCTAssertFalse(TextListFormatter.listsOfAnyKindPresent(in: textView.typingAttributes)) + } } From ec35ce0fffd16bdd45eb60ad47951b937d8bcf8e Mon Sep 17 00:00:00 2001 From: Jorge Leandro Perez Date: Tue, 18 Apr 2017 17:50:56 -0300 Subject: [PATCH 23/23] TextView: Addressing Unit Tests Comments --- Aztec/Classes/TextKit/TextView.swift | 29 ++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 8c154aee6..390304fb5 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -769,16 +769,22 @@ open class TextView: UITextView { /// Removes the List Attributes from a collection of attributes, whenever: /// - /// A. The caret is at the very end of the document (+ there is no selected text) + /// A. The selected location is at the very end of the document /// B. There's a list! /// C. The previous character is a '\n' /// + /// This is necessary because when the caret is at EOF, and the previous `\n` character has + /// a textList style, that style will remain in the `typingAttributes`. We'll only allow the style + /// to remain if there are contents in the current line with the textList style (in which case + /// this condition won't ever trigger because we'll either no longer be at EOF, or the previous + /// character won't be `\n`). + /// /// - Parameter attributes: Typing Attributes. /// /// - Returns: Updated Typing Attributes. /// private func ensureRemovalOfListAttribute(from attributes: [String: Any]) -> [String: Any] { - guard selectedRange.location == storage.length && selectedRange.length == 0 else { + guard selectedRange.location == storage.length else { return attributes } @@ -792,10 +798,18 @@ open class TextView: UITextView { return attributes } - var updatedAttributes = attributes - updatedAttributes[NSParagraphStyleAttributeName] = ParagraphStyle.default - return updatedAttributes + let orderedListFormatter = TextListFormatter(style: .ordered) + if orderedListFormatter.present(in: attributes) { + return orderedListFormatter.remove(from: attributes) + } + + let unorderedListFormatter = TextListFormatter(style: .unordered) + if unorderedListFormatter.present(in: attributes) { + return unorderedListFormatter.remove(from: attributes) + } + + return attributes } @@ -806,14 +820,13 @@ open class TextView: UITextView { return } - // Note: We *really* need to use super, so that we prevent recursive loops. let previousRange = selectedRange - let previousStyle = super.typingAttributes + let previousStyle = typingAttributes super.insertText(String(.newline)) selectedRange = previousRange - super.typingAttributes = previousStyle + typingAttributes = previousStyle }