diff --git a/Aztec/Classes/Formatters/AttributeFormatter.swift b/Aztec/Classes/Formatters/AttributeFormatter.swift index 8bde432fa..25b95f047 100644 --- a/Aztec/Classes/Formatters/AttributeFormatter.swift +++ b/Aztec/Classes/Formatters/AttributeFormatter.swift @@ -64,8 +64,6 @@ protocol AttributeFormatter { func applicationRange(for range: NSRange, in text: NSAttributedString) -> NSRange func worksInEmptyRange() -> Bool - - func needsEmptyLinePlaceholder() -> Bool } @@ -120,15 +118,9 @@ extension AttributeFormatter { /// @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) { - let placeholder = placeholderForEmptyLine(using: placeholderAttributes) - text.insert(placeholder, at: rangeToApply.location) - rangeToApply = NSMakeRange(rangeToApply.location, placeholder.length) - } + let rangeToApply = applicationRange(for: range, in: text) - text.enumerateAttributes(in: rangeToApply, options: []) { (attributes, range, stop) in + text.enumerateAttributes(in: rangeToApply, options: []) { (attributes, range, _) in let currentAttributes = text.attributes(at: range.location, effectiveRange: nil) let attributes = apply(to: currentAttributes) text.addAttributes(attributes, range: range) @@ -180,12 +172,6 @@ extension AttributeFormatter { // private extension AttributeFormatter { - /// The string to be used when adding attributes to an empty line. - /// - func placeholderForEmptyLine(using attributes: [String: Any]?) -> NSAttributedString { - return VisualOnlyElementFactory().zeroWidthSpace(inheritingAttributes: attributes) - } - /// Helper that indicates whether if we should format the specified range, or not. /// - Note: For convenience reasons, whenever the Text is empty, this helper will return *true*. /// @@ -215,10 +201,6 @@ extension CharacterAttributeFormatter { func worksInEmptyRange() -> Bool { return false } - - func needsEmptyLinePlaceholder() -> Bool { - return false - } } @@ -236,8 +218,4 @@ extension ParagraphAttributeFormatter { func worksInEmptyRange() -> Bool { return true } - - func needsEmptyLinePlaceholder() -> Bool { - return true - } } diff --git a/Aztec/Classes/Formatters/BlockquoteFormatter.swift b/Aztec/Classes/Formatters/BlockquoteFormatter.swift index 8ab079b00..0239fbca3 100644 --- a/Aztec/Classes/Formatters/BlockquoteFormatter.swift +++ b/Aztec/Classes/Formatters/BlockquoteFormatter.swift @@ -66,9 +66,5 @@ class BlockquoteFormatter: ParagraphAttributeFormatter { let style = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle return style?.blockquote != nil } - - func needsEmptyLinePlaceholder() -> Bool { - return false - } } diff --git a/Aztec/Classes/Formatters/HeaderFormatter.swift b/Aztec/Classes/Formatters/HeaderFormatter.swift index 5e9d302c2..c9d4ce7f2 100644 --- a/Aztec/Classes/Formatters/HeaderFormatter.swift +++ b/Aztec/Classes/Formatters/HeaderFormatter.swift @@ -1,8 +1,13 @@ import Foundation import UIKit + +// MARK: - Header Formatter +// open class HeaderFormatter: ParagraphAttributeFormatter { + /// Available Heading Types + /// public enum HeaderType: Int { case none = 0 case h1 = 1 @@ -37,15 +42,25 @@ open class HeaderFormatter: ParagraphAttributeFormatter { } } + /// Heading Level of this formatter + /// let headerLevel: HeaderType + /// Attributes to be added by default + /// let placeholderAttributes: [String : Any]? + + /// Designated Initializer + /// init(headerLevel: HeaderType = .h1, placeholderAttributes: [String : Any]? = nil) { self.headerLevel = headerLevel self.placeholderAttributes = placeholderAttributes } + + // MARK: - Overwriten Methods + func apply(to attributes: [String : Any]) -> [String: Any] { var resultingAttributes = attributes let newParagraphStyle = ParagraphStyle() @@ -97,9 +112,5 @@ open class HeaderFormatter: ParagraphAttributeFormatter { } return false } - - func needsEmptyLinePlaceholder() -> Bool { - return false - } } diff --git a/Aztec/Classes/Formatters/PreFormatter.swift b/Aztec/Classes/Formatters/PreFormatter.swift index 959e774f3..ec599138c 100644 --- a/Aztec/Classes/Formatters/PreFormatter.swift +++ b/Aztec/Classes/Formatters/PreFormatter.swift @@ -1,16 +1,30 @@ import Foundation import UIKit + +// MARK: - Pre Formatter +// open class PreFormatter: ParagraphAttributeFormatter { + /// Font to be used + /// let monospaceFont: UIFont + + /// Attributes to be added by default + /// let placeholderAttributes: [String : Any]? + + /// Designated Initializer + /// init(monospaceFont: UIFont = UIFont(descriptor:UIFontDescriptor(name: "Courier", size: 12), size:12), placeholderAttributes: [String : Any]? = nil) { self.monospaceFont = monospaceFont self.placeholderAttributes = placeholderAttributes } + + // MARK: - Overwriten Methods + func apply(to attributes: [String : Any]) -> [String: Any] { var resultingAttributes = attributes let newParagraphStyle = ParagraphStyle() @@ -35,10 +49,7 @@ open class PreFormatter: ParagraphAttributeFormatter { } func present(in attributes: [String : Any]) -> Bool { - if let font = attributes[NSFontAttributeName] as? UIFont { - return font == monospaceFont - } - return false + let font = attributes[NSFontAttributeName] as? UIFont + return font == monospaceFont } } - diff --git a/Aztec/Classes/Formatters/TextListFormatter.swift b/Aztec/Classes/Formatters/TextListFormatter.swift index 2c803706b..0db9e6844 100644 --- a/Aztec/Classes/Formatters/TextListFormatter.swift +++ b/Aztec/Classes/Formatters/TextListFormatter.swift @@ -71,10 +71,6 @@ class TextListFormatter: ParagraphAttributeFormatter { 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/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index ae9929bb1..1c6ed8ca2 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -215,12 +215,12 @@ open class TextView: UITextView { // MARK: - Overwritten Properties /// Overwrites Typing Attributes: - /// This is the (only) valid hook we've found, in order to (selectively) remove the Blockquote/List attributes. + /// This is the (only) valid hook we've found, in order to (selectively) remove the [Blockquote, List, Pre] attributes. /// For details, see: https://github.com/wordpress-mobile/AztecEditor-iOS/issues/414 /// override open var typingAttributes: [String: Any] { get { - let updatedAttributes = ensureRemovalOfListAndBlockquoteAttribute(from: super.typingAttributes) + let updatedAttributes = ensureRemovalOfParagraphAttributes(from: super.typingAttributes) super.typingAttributes = updatedAttributes return updatedAttributes @@ -288,7 +288,7 @@ open class TextView: UITextView { open override func insertText(_ text: String) { - /// Whenever the user is at the end of the document, while editing a [List, Blockquote], we'll need + /// Whenever the user is at the end of the document, while editing a [List, Blockquote, Pre], we'll need /// to insert a `\n` character, so that the Layout Manager immediately renders the List's new bullet /// (or Blockquote's BG). /// @@ -576,6 +576,23 @@ open class TextView: UITextView { toggle(formatter: formatter, atRange: range) } + /// Adds or removes a Pre style from the specified range. + /// Pre are applied to an entire paragrah regardless of the range. + /// If the range spans multiple paragraphs, the style is applied to all + /// affected paragraphs. + /// + /// - Parameters: + /// - range: The NSRange to edit. + /// + open func togglePre(range: NSRange) { + ensureInsertionOfNewlineWhenEditingEdgeOfTheDocument() + + let formatter = PreFormatter(placeholderAttributes: typingAttributes) + toggle(formatter: formatter, atRange: range) + + forceRedrawCursorAfterDelay() + } + /// Adds or removes a blockquote style from the specified range. /// Blockquotes are applied to an entire paragrah regardless of the range. /// If the range spans multiple paragraphs, the style is applied to all @@ -739,16 +756,17 @@ open class TextView: UITextView { /// /// - Returns: True if ParagraphAttributes were removed. False otherwise! /// - func ensureRemovalOfParagraphAttributes(insertedText text: String, at range: NSRange) -> Bool { + private func ensureRemovalOfParagraphAttributes(insertedText text: String, at range: NSRange) -> Bool { guard shouldRemoveParagraphAttributes(insertedText: text, at: range.location) else { return false } - let formatters:[AttributeFormatter] = [ + let formatters: [AttributeFormatter] = [ TextListFormatter(style: .ordered), TextListFormatter(style: .unordered), - BlockquoteFormatter() + BlockquoteFormatter(), + PreFormatter(placeholderAttributes: self.defaultAttributes) ] let atEdgeOfDocument = range.location >= storage.length @@ -773,10 +791,10 @@ open class TextView: UITextView { /// /// A. The selected location is at the very end of the document /// B. The previous character is a '\n' - /// C. There's a list (OR) blockquote. + /// C. There's a List (OR) Blockquote (OR) Pre. /// /// This is necessary because when the caret is at EOF, and the previous `\n` character has - /// a [List, Blockquote] styles, that style will remain in the `typingAttributes`. We'll only + /// a [List, Blockquote, Pre] styles, 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`). @@ -785,7 +803,7 @@ open class TextView: UITextView { /// /// - Returns: Updated Typing Attributes. /// - private func ensureRemovalOfListAndBlockquoteAttribute(from typingAttributes: [String: Any]) -> [String: Any] { + private func ensureRemovalOfParagraphAttributes(from typingAttributes: [String: Any]) -> [String: Any] { guard selectedRange.location == storage.length else { return typingAttributes } @@ -797,9 +815,10 @@ open class TextView: UITextView { } let formatters: [AttributeFormatter] = [ + BlockquoteFormatter(), + PreFormatter(placeholderAttributes: self.defaultAttributes), TextListFormatter(style: .ordered), - TextListFormatter(style: .unordered), - BlockquoteFormatter() + TextListFormatter(style: .unordered) ] for formatter in formatters where formatter.present(in: typingAttributes) { @@ -825,7 +844,7 @@ open class TextView: UITextView { /// /// A. We're about to insert a new line /// B. We're at the end of the document - /// C. There's a List (OR) Blockquote active + /// C. There's a List (OR) Blockquote (OR) Pre active /// /// We're doing this as a workaround, in order to force the LayoutManager render the Bullet (OR) /// Blockquote's background. @@ -841,6 +860,7 @@ open class TextView: UITextView { let formatters: [AttributeFormatter] = [ BlockquoteFormatter(), + PreFormatter(placeholderAttributes: self.defaultAttributes), TextListFormatter(style: .ordered), TextListFormatter(style: .unordered) ] diff --git a/AztecTests/TextViewTests.swift b/AztecTests/TextViewTests.swift index c66c1c9b9..e828268c5 100644 --- a/AztecTests/TextViewTests.swift +++ b/AztecTests/TextViewTests.swift @@ -1101,5 +1101,192 @@ class AztecVisualTextViewTests: XCTestCase { XCTAssertEqual(textView.text, Constants.sampleText0 + Constants.sampleText1 + String(.newline) + String(.newline)) } + + + // MARK: - Pre + + /// Verifies that a Pre does not get removed whenever the user presses backspace + /// + /// Input: + /// - Pre + /// - Text: Constants.sampleText0 + /// - Backspace + /// + /// Ref. Issue https://github.com/wordpress-mobile/AztecEditor-iOS/issues/420 + /// + func testPreDoesNotGetLostAfterPressingBackspace() { + let textView = createTextView(withHTML: "") + + textView.togglePre(range: .zero) + textView.insertText(Constants.sampleText0) + textView.deleteBackward() + + let formatter = PreFormatter() + let range = textView.storage.rangeOfEntireString + + XCTAssertTrue(formatter.present(in: textView.storage, at: range)) + } + + /// Verifies that the Pre Style gets nuked whenever the only `\n` present in the document is deleted. + /// + /// Input: + /// - Pre + /// - Selection of the EOD + /// - Backspace + /// + /// Ref. Issue https://github.com/wordpress-mobile/AztecEditor-iOS/issues/420 + /// + func testEmptyPreGetsNukedWheneverTheOnlyNewlineCharacterInTheDocumentIsNuked() { + let textView = createTextView(withHTML: "") + + textView.togglePre(range: .zero) + textView.selectedRange = textView.text.endOfStringNSRange() + textView.deleteBackward() + + let formatter = PreFormatter() + + XCTAssertFalse(formatter.present(in: textView.typingAttributes)) + XCTAssert(textView.storage.length == 0) + } + + /// Verifies that New Line Characters get effectively inserted after a Pre. + /// + /// Input: + /// - Pre + /// - \n at the end of the document + /// + /// Ref. Issue https://github.com/wordpress-mobile/AztecEditor-iOS/issues/420 + /// + func testNewLinesAreInsertedAfterEmptyPre() { + let newline = String(.newline) + let textView = createTextView(withHTML: "") + + textView.togglePre(range: .zero) + textView.selectedRange = textView.text.endOfStringNSRange() + + var expectedLength = textView.text.characters.count + textView.insertText(newline) + expectedLength += newline.characters.count + + XCTAssertEqual(textView.text.characters.count, expectedLength) + } + + /// Verifies that New Pre Lines do get their style, even when the ending `\n` character was deleted. + /// + /// Input: + /// - Blockquote + /// - Text: Constants.sampleText0 + /// - Selection of the `\n` at the EOD, and backspace + /// - Text: "\n" + /// - Text: Constants.sampleText1 + /// + /// Ref. Issue https://github.com/wordpress-mobile/AztecEditor-iOS/issues/420 + /// + func testNewLinesGetPreStyleEvenAfterDeletingEndOfDocumentNewline() { + let newline = String(.newline) + + let textView = createTextView(withHTML: "") + + textView.togglePre(range: .zero) + textView.insertText(Constants.sampleText0) + textView.selectedRange = textView.text.endOfStringNSRange() + + // Delete + Insert Newline + textView.deleteBackward() + textView.insertText(newline) + textView.insertText(Constants.sampleText1) + + // Verify it's still present + let secondLineIndex = Constants.sampleText0.characters.count + newline.characters.count + let secondLineRange = NSRange(location: secondLineIndex, length: Constants.sampleText1.characters.count) + + let formatter = PreFormatter() + let present = formatter.present(in: textView.storage, at: secondLineRange) + + XCTAssert(present) + } + + /// Verifies that after selecting a newline below a Pre, TextView wil not render (nor carry over) + /// the Pre formatting attributes. + /// + /// Input: + /// - Pre + /// - Selection of the `\n` at the EOD + /// + /// Ref. Issue https://github.com/wordpress-mobile/AztecEditor-iOS/issues/420 + /// + func testTypingAttributesLoosePreWhenSelectingAnEmptyNewlineBelowPre() { + let textView = createTextView(withHTML: "") + + textView.togglePre(range: .zero) + textView.selectedRange = textView.text.endOfStringNSRange() + + XCTAssertFalse(PreFormatter().present(in: textView.typingAttributes)) + } + + /// Verifies that Pre get removed whenever the user types `\n` in an empty line. + /// + /// Input: + /// - Pre + /// - `\n` on the first line + /// + /// Ref. Issue https://github.com/wordpress-mobile/AztecEditor-iOS/issues/420 + /// + func testPreGetsRemovedWhenTypingNewLineOnAnEmptyPreLine() { + let textView = createTextView(withHTML: "") + + textView.togglePre(range: .zero) + textView.insertText(String(.newline)) + + let formatter = PreFormatter() + let attributedText = textView.attributedText! + + for location in 0 ..< attributedText.length { + XCTAssertFalse(formatter.present(in: attributedText, at: location)) + } + + XCTAssertFalse(formatter.present(in: textView.typingAttributes)) + } + + /// Verifies that toggling a Pre, when editing an empty document, inserts a Newline. + /// + /// Input: + /// - Pre + /// + /// Ref. Issue https://github.com/wordpress-mobile/AztecEditor-iOS/issues/420 + /// + func testTogglingPreOnEmptyDocumentsInsertsNewline() { + let textView = createTextView(withHTML: "") + + textView.togglePre(range: .zero) + XCTAssertEqual(textView.text, String(.newline)) + } + + /// Verifies that toggling a Pre, when editing the end of a non empty document, inserts a Newline. + /// + /// Input: + /// - Text: Constants.sampleText0 + /// - Selection of the end of document + /// - Blockquote + /// - Backspace + /// - Text: Constants.sampleText1 + /// - Text: newline + /// + /// Ref. Issue https://github.com/wordpress-mobile/AztecEditor-iOS/issues/420 + /// + func testTogglingPreOnNonEmptyDocumentsWhenSelectedRangeIsAtTheEndOfDocumentWillInsertNewline() { + let textView = createTextView(withHTML: Constants.sampleText0) + + textView.selectedRange = textView.text.endOfStringNSRange() + textView.togglePre(range: .zero) + XCTAssertEqual(textView.text, Constants.sampleText0 + String(.newline)) + + textView.selectedRange = textView.text.endOfStringNSRange() + textView.deleteBackward() + textView.insertText(Constants.sampleText1) + textView.insertText(String(.newline)) + + XCTAssertEqual(textView.text, Constants.sampleText0 + Constants.sampleText1 + String(.newline) + String(.newline)) + } }