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/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) diff --git a/Aztec/Classes/Formatters/TextListFormatter.swift b/Aztec/Classes/Formatters/TextListFormatter.swift index 8fed9069b..2c803706b 100644 --- a/Aztec/Classes/Formatters/TextListFormatter.swift +++ b/Aztec/Classes/Formatters/TextListFormatter.swift @@ -1,53 +1,83 @@ 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 - let newParagraphStyle = ParagraphStyle() + func remove(from attributes: [String: Any]) -> [String: Any] { guard let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, paragraphStyle.textList?.style == self.listStyle else { - return resultingAttributes + return attributes } + + let newParagraphStyle = 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 + } + + 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/LayoutManager.swift b/Aztec/Classes/TextKit/LayoutManager.swift index 8183709a5..7f770620c 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 /// @@ -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) } } 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 diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 1b968cf7e..390304fb5 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) @@ -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?) { @@ -267,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. @@ -497,7 +525,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,8 +596,11 @@ open class TextView: UITextView { /// - Parameter range: The NSRange to edit. /// open func toggleOrderedList(range: NSRange) { + ensureInsertionOfNewlineOnEmptyDocuments() + let formatter = TextListFormatter(style: .ordered, placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) + forceRedrawCursorAfterDelay() } @@ -579,8 +610,11 @@ open class TextView: UITextView { /// - Parameter range: The NSRange to edit. /// open func toggleUnorderedList(range: NSRange) { + ensureInsertionOfNewlineOnEmptyDocuments() + let formatter = TextListFormatter(style: .unordered, placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) + forceRedrawCursorAfterDelay() } @@ -612,9 +646,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), @@ -635,14 +667,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) } } } @@ -712,9 +743,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 } @@ -724,6 +767,69 @@ open class TextView: UITextView { } + /// Removes the List Attributes from a collection of attributes, whenever: + /// + /// 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 else { + return attributes + } + + guard TextListFormatter.listsOfAnyKindPresent(in: attributes) else { + return attributes + } + + let previousRange = NSRange(location: selectedRange.location - 1, length: 1) + let previousString = storage.safeSubstring(at: previousRange) ?? String(.newline) + guard previousString == String(.newline) else { + return attributes + } + + + 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 + } + + + /// 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 + + super.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: 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)) + } + } } diff --git a/AztecTests/TextViewTests.swift b/AztecTests/TextViewTests.swift index cffd40eb0..a59c77a31 100644 --- a/AztecTests/TextViewTests.swift +++ b/AztecTests/TextViewTests.swift @@ -679,4 +679,164 @@ class AztecVisualTextViewTests: XCTestCase { XCTAssertEqual(textView.getHTML(), "

Header

1") } + + + // MARK: - Lists + + /// 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. Scenario Mark III on Issue 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. Scenario Mark IV on Issue 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) + } + + /// 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)) + } + + /// 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)) + } }