From 6f539ae0eb93d2815d9ec85f4fa95e141d82eef4 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Thu, 6 Apr 2017 11:39:50 -0300 Subject: [PATCH 01/25] Work in progress. --- .../Libxml2/DOM/Data/ElementNode.swift | 13 +++++-- Aztec/Classes/Libxml2/DOMString.swift | 25 +++++++++++--- .../Descriptors/ElementNodeDescriptor.swift | 10 +++--- Aztec/Classes/TextKit/TextStorage.swift | 34 +++++++++++++++++-- 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift index 373f0eec3..0605ed6ca 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift @@ -1510,16 +1510,24 @@ extension Libxml2 { @discardableResult func wrap(children selectedChildren: [Node], inElement elementDescriptor: ElementNodeDescriptor) -> ElementNode { + var childrenToWrap = selectedChildren + + if let childElementDescriptor = elementDescriptor.childDescriptor { + let newChild = wrap(children: selectedChildren, inElement: childElementDescriptor) + + childrenToWrap = [newChild] + } + guard selectedChildren.count > 0 else { assertionFailure("Avoid calling this method with no nodes.") return ElementNode(descriptor: elementDescriptor, editContext: editContext) } - guard let firstNodeIndex = children.index(of: selectedChildren[0]) else { + guard let firstNodeIndex = children.index(of: childrenToWrap[0]) else { fatalError("A node's parent should contain the node. Review the child/parent updating logic.") } - guard let lastNodeIndex = children.index(of: selectedChildren[selectedChildren.count - 1]) else { + guard let lastNodeIndex = children.index(of: childrenToWrap[childrenToWrap.count - 1]) else { fatalError("A node's parent should contain the node. Review the child/parent updating logic.") } @@ -1537,7 +1545,6 @@ extension Libxml2 { let rightSibling = pushUp(siblingOrDescendantAtRightSideOf: lastNodeIndex, evaluatedBy: evaluation, bailIf: bailEvaluation) let leftSibling = pushUp(siblingOrDescendantAtLeftSideOf: firstNodeIndex, evaluatedBy: evaluation, bailIf: bailEvaluation) - var childrenToWrap = selectedChildren var result: ElementNode? if let sibling = rightSibling { diff --git a/Aztec/Classes/Libxml2/DOMString.swift b/Aztec/Classes/Libxml2/DOMString.swift index 2840e3070..c021f3195 100644 --- a/Aztec/Classes/Libxml2/DOMString.swift +++ b/Aztec/Classes/Libxml2/DOMString.swift @@ -454,14 +454,16 @@ extension Libxml2 { } } - private func elementTypeForHeaderLevel(_ headerLevel: Int) -> StandardElementType? { - if headerLevel < 1 && headerLevel > DOMString.headerLevels.count { - return nil + func applyOrderedList(spanning range: NSRange) { + performAsyncUndoable { [weak self] in + + let liDescriptor = ElementNodeDescriptor(elementType: .li) + let olDescriptor = ElementNodeDescriptor(elementType: .ol, childDescriptor: liDescriptor) + + self?.applyElementDescriptor(olDescriptor, spanning: range) } - return DOMString.headerLevels[headerLevel - 1] } - func applyHeader(_ headerLevel:Int, spanning range:NSRange) { guard let elementType = elementTypeForHeaderLevel(headerLevel) else { return @@ -471,6 +473,15 @@ extension Libxml2 { } } + // MARK: - Header types + + private func elementTypeForHeaderLevel(_ headerLevel: Int) -> StandardElementType? { + if headerLevel < 1 && headerLevel > DOMString.headerLevels.count { + return nil + } + return DOMString.headerLevels[headerLevel - 1] + } + // MARK: - Images /// Replaces the specified range with a given image. @@ -557,6 +568,10 @@ extension Libxml2 { fileprivate func applyElement(_ elementName: String, spanning range: NSRange, equivalentElementNames: [String], attributes: [Attribute] = []) { let elementDescriptor = ElementNodeDescriptor(name: elementName, attributes: attributes, matchingNames: equivalentElementNames) + applyElementDescriptor(elementDescriptor, spanning: range) + } + + private func applyElementDescriptor(_ elementDescriptor: ElementNodeDescriptor, spanning range: NSRange) { domEditor.wrapChildren(intersectingRange: range, inElement: elementDescriptor) } diff --git a/Aztec/Classes/Libxml2/Descriptors/ElementNodeDescriptor.swift b/Aztec/Classes/Libxml2/Descriptors/ElementNodeDescriptor.swift index 1d558f030..e95e451b9 100644 --- a/Aztec/Classes/Libxml2/Descriptors/ElementNodeDescriptor.swift +++ b/Aztec/Classes/Libxml2/Descriptors/ElementNodeDescriptor.swift @@ -9,8 +9,9 @@ extension Libxml2 { /// class ElementNodeDescriptor: NodeDescriptor { let attributes: [Attribute] + let childDescriptor: ElementNodeDescriptor? let matchingNames: [String] - + // MARK: - CustomReflectable public override var customMirror: Mirror { @@ -19,14 +20,15 @@ extension Libxml2 { } } - init(name: String, attributes: [Attribute] = [], matchingNames: [String] = []) { + init(name: String, childDescriptor: ElementNodeDescriptor? = nil, attributes: [Attribute] = [], matchingNames: [String] = []) { self.attributes = attributes + self.childDescriptor = childDescriptor self.matchingNames = matchingNames super.init(name: name) } - convenience init(elementType: StandardElementType, attributes: [Attribute] = []) { - self.init(name: elementType.rawValue, attributes: attributes, matchingNames: elementType.equivalentNames) + convenience init(elementType: StandardElementType, childDescriptor: ElementNodeDescriptor? = nil, attributes: [Attribute] = []) { + self.init(name: elementType.rawValue, childDescriptor: childDescriptor, attributes: attributes, matchingNames: elementType.equivalentNames) } // MARK: - Introspection diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index fb32bd749..5fc013d3d 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -249,7 +249,12 @@ open class TextStorage: NSTextStorage { // MARK: - Overriden Methods override open func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [String : Any] { - return textStore.length == 0 ? [:] : textStore.attributes(at: location, effectiveRange: range) + + var attributes = textStore.length == 0 ? [:] : textStore.attributes(at: location, effectiveRange: range) + + attributes[VisualOnlyAttributeName] = nil + + return attributes } override open func replaceCharacters(in range: NSRange, with str: String) { @@ -288,7 +293,11 @@ open class TextStorage: NSTextStorage { dom.deleteBlockSeparator(at: targetDomRange.location) } + print("Pre: \(dom.getHTML())") + applyStylesToDom(from: domString, startingAt: range.location) + + print("Pos: \(dom.getHTML())") } detectAttachmentRemoved(in: range) @@ -401,8 +410,9 @@ open class TextStorage: NSTextStorage { case NSParagraphStyleAttributeName: let sourceStyle = sourceValue as? ParagraphStyle let targetStyle = targetValue as? ParagraphStyle - processBlockquoteDifferences(in: domRange, betweenOriginal: sourceStyle?.blockquote, andNew: targetStyle?.blockquote) + processBlockquoteDifferences(in: domRange, betweenOriginal: sourceStyle?.blockquote, andNew: targetStyle?.blockquote) + processListDifferences(in: domRange, betweenOriginal: sourceStyle?.textList, andNew: targetStyle?.textList) processHeaderDifferences(in: domRange, betweenOriginal: sourceStyle?.headerLevel, andNew: targetStyle?.headerLevel) case NSLinkAttributeName: let sourceStyle = sourceValue as? URL @@ -584,6 +594,26 @@ open class TextStorage: NSTextStorage { } } + /// Processes differences in list styles, and applies them to the DOM in the specified + /// range. + /// + /// - Parameters: + /// - range: the range in the DOM where the differences must be applied. + /// - originalStyle: the original TextList object if any. + /// - newStyle: the new Blockquote object. + /// + private func processListDifferences(in range: NSRange, betweenOriginal originalStyle: TextList?, andNew newStyle: TextList?) { + + let addStyle = originalStyle == nil && newStyle != nil + let removeStyle = originalStyle != nil && newStyle == nil + + if addStyle { + dom.applyOrderedList(spanning: range) + } else if removeStyle { + dom.removeBlockquote(spanning: range) + } + } + /// Processes differences in header styles, and applies them to the DOM in the specified /// range. /// From 96cc5f83ef38cdcb64909265f86040000491c11e Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 10 Apr 2017 10:06:48 -0300 Subject: [PATCH 02/25] Committing for pair programming. --- Aztec/Classes/TextKit/TextStorage.swift | 4 +++- AztecTests/TextViewTests.swift | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index 3b4921d60..3811467c4 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -270,7 +270,7 @@ open class TextStorage: NSTextStorage { endEditing() } - + override open func replaceCharacters(in range: NSRange, with attrString: NSAttributedString) { let preprocessedString = preprocessAttributesForInsertion(attrString) @@ -313,6 +313,8 @@ open class TextStorage: NSTextStorage { edited(.editedAttributes, range: range, changeInLength: 0) endEditing() + + print("Style: \(dom.getHTML())") } // MARK: - Entry point for calculating style differences diff --git a/AztecTests/TextViewTests.swift b/AztecTests/TextViewTests.swift index a7ee8606f..395a0a557 100644 --- a/AztecTests/TextViewTests.swift +++ b/AztecTests/TextViewTests.swift @@ -592,7 +592,7 @@ class AztecVisualTextViewTests: XCTestCase { let html = "

Header

\n" let textView = createTextView(withHTML: html) - let range = NSRange(location:html.characters.count, length:0) + let range = NSRange(location: textView.text.characters.count, length:0) textView.selectedRange = range textView.deleteBackward() From 5aeb11b9b32e9e279017ed81dd5997a11b1aeda3 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Fri, 21 Apr 2017 18:34:12 -0300 Subject: [PATCH 03/25] Adds some flags to try a hack to fix a really annoying issue. --- Aztec/Classes/TextKit/TextStorage.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index 4b6214c81..e844513ba 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -76,6 +76,8 @@ open class TextStorage: NSTextStorage { fileprivate var textStore = NSMutableAttributedString(string: "", attributes: nil) fileprivate let dom = Libxml2.DOMString() + private var allowFixingDOMAttributes = true + // MARK: - Undo Support public var undoManager: UndoManager? { @@ -280,13 +282,15 @@ open class TextStorage: NSTextStorage { textStore.replaceCharacters(in: range, with: preprocessedString) edited([.editedAttributes, .editedCharacters], range: range, changeInLength: attrString.length - range.length) + allowFixingDOMAttributes = false endEditing() + allowFixingDOMAttributes = true } override open func setAttributes(_ attrs: [String : Any]?, range: NSRange) { beginEditing() - if mustUpdateDOM(), let attributes = attrs { + if mustUpdateDOM() && allowFixingDOMAttributes, let attributes = attrs { applyStylesToDom(attributes: attributes, in: range) } From a99629277d8c513414bfbc6d4a5430732772fd47 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Sat, 22 Apr 2017 11:45:06 -0300 Subject: [PATCH 04/25] Adds a few improvements for newline handling. --- Aztec/Classes/TextKit/TextStorage.swift | 32 +++++++++++++++++++++---- Aztec/Classes/TextKit/TextView.swift | 2 +- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index e844513ba..16b5d8596 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -76,6 +76,11 @@ open class TextStorage: NSTextStorage { fileprivate var textStore = NSMutableAttributedString(string: "", attributes: nil) fileprivate let dom = Libxml2.DOMString() + // MARK: - Workarounds support + + /// To know more about why we need this flag, check the documentation of our `endEditing()` + /// override. + /// private var allowFixingDOMAttributes = true // MARK: - Undo Support @@ -144,6 +149,12 @@ open class TextStorage: NSTextStorage { // MARK: - NSAttributedString preprocessing + private func preprocessStringForInsertion(_ attributedString: NSAttributedString) -> NSAttributedString { + let stringWithFixedNewlines = NSAttributedString(with: attributedString, replacingOcurrencesOf: "\n", with: String(.paragraphSeparator)) + + return preprocessAttributesForInsertion(stringWithFixedNewlines) + } + private func preprocessAttributesForInsertion(_ attributedString: NSAttributedString) -> NSAttributedString { let stringWithAttachments = preprocessAttachmentsForInsertion(attributedString) let stringWithParagraphs = preprocessParagraphsForInsertion(stringWithAttachments) @@ -270,7 +281,7 @@ open class TextStorage: NSTextStorage { override open func replaceCharacters(in range: NSRange, with attrString: NSAttributedString) { - let preprocessedString = preprocessAttributesForInsertion(attrString) + let preprocessedString = preprocessStringForInsertion(attrString) beginEditing() @@ -282,9 +293,7 @@ open class TextStorage: NSTextStorage { textStore.replaceCharacters(in: range, with: preprocessedString) edited([.editedAttributes, .editedCharacters], range: range, changeInLength: attrString.length - range.length) - allowFixingDOMAttributes = false endEditing() - allowFixingDOMAttributes = true } override open func setAttributes(_ attrs: [String : Any]?, range: NSRange) { @@ -302,6 +311,19 @@ open class TextStorage: NSTextStorage { print("Style: \(dom.getHTML())") } + /// This override exists to prevent text replacement from propagating style-changes to the DOM + /// This should not be a problem in our logic because the DOM is smart enough to update its + /// style after text modifications. + /// + /// This was causing issues specifically with lists. To understand why, just comment this + /// method and run the unit tests. + /// + override open func endEditing() { + allowFixingDOMAttributes = false + super.endEditing() + allowFixingDOMAttributes = true + } + // MARK: - DOM: Replacing Characters private func replaceCharactersInDOM(in range: NSRange, with str: String) { @@ -333,7 +355,9 @@ open class TextStorage: NSTextStorage { } print("Pre: \(dom.getHTML())") - applyStylesToDom(from: domString, startingAt: range.location) + if domString.length > 0 { + applyStylesToDom(from: domString, startingAt: range.location) + } print("Pos: \(dom.getHTML())") } diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 3aee59827..397c4bde5 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -767,7 +767,7 @@ open class TextView: UITextView { let previousRange = selectedRange let previousStyle = typingAttributes - super.insertText(String(.newline)) + super.insertText(String(.paragraphSeparator)) selectedRange = previousRange typingAttributes = previousStyle From 625aea85f297ed50d721afebcba3701b5a9b5927 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 03:54:18 -0300 Subject: [PATCH 05/25] WIP: Several improvements for style management. --- .../Libxml2/DOM/Data/ElementNode.swift | 126 ++++++++---------- Aztec/Classes/Libxml2/DOM/Data/Node.swift | 2 +- Aztec/Classes/Libxml2/DOM/Data/TextNode.swift | 2 +- .../Classes/Libxml2/DOM/Logic/DOMEditor.swift | 7 - Aztec/Classes/Libxml2/DOMString.swift | 8 +- Aztec/Classes/TextKit/TextStorage.swift | 87 ++---------- Aztec/Classes/TextKit/TextView.swift | 39 +++--- 7 files changed, 95 insertions(+), 176 deletions(-) diff --git a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift index 89616d22d..df8245fc5 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift @@ -172,7 +172,7 @@ extension Libxml2 { return standardName.isBlockLevelNodeName() } - func isNodeType(_ type:StandardElementType) -> Bool { + func isNodeType(_ type: StandardElementType) -> Bool { return type.equivalentNames.contains(name.lowercased()) } @@ -494,6 +494,11 @@ extension Libxml2 { return } + guard children.count > 0 else { + matchNotFound?(targetRange) + return + } + var rangeWithoutMatch: NSRange? var offset = Int(0) @@ -526,7 +531,6 @@ extension Libxml2 { if isMatch(child) { processMatchFound(child, intersection) } else if let childElement = child as? ElementNode { - let intersectionInChildCoordinates = NSRange(location: intersection.location - offset, length: intersection.length) childElement.enumerateFirstDescendants( @@ -691,13 +695,20 @@ extension Libxml2 { guard childIndex >= 0 && childIndex < children.count else { fatalError("Out of bounds!") } - - guard childIndex > 0, - let sibling = children[childIndex - 1] as? T else { - return nil + + guard childIndex > 0 else { + return nil } - - return sibling + + let siblingNode = children[childIndex - 1] + + // Ignore empty text nodes. + // + if let textSibling = siblingNode as? TextNode, textSibling.length() == 0 { + return sibling(leftOf: childIndex - 1) + } + + return siblingNode as? T } /// Retrieves the right-side sibling of the child at the specified index. @@ -714,12 +725,19 @@ extension Libxml2 { fatalError("Out of bounds!") } - guard childIndex < children.count - 1, - let sibling = children[childIndex + 1] as? T else { - return nil + guard childIndex < children.count - 1 else { + return nil } - - return sibling + + let siblingNode = children[childIndex + 1] + + // Ignore empty text nodes. + // + if let textSibling = siblingNode as? TextNode, textSibling.length() == 0 { + return sibling(rightOf: childIndex + 1) + } + + return siblingNode as? T } /// Finds any left-side descendant with any of the specified names. @@ -822,8 +840,15 @@ extension Libxml2 { /// func append(_ child: Node) { child.removeFromParent() - children.append(child) - child.parent = self + + if let lastChild = children.last as? TextNode, + let newChildTextNode = child as? TextNode { + + lastChild.append(newChildTextNode.text()) + } else { + children.append(child) + child.parent = self + } } /// Appends a node to the list of children for this element. @@ -1184,7 +1209,7 @@ extension Libxml2 { // MARK: - EditableNode override func deleteCharacters(inRange range: NSRange) { - if range.location == 0 && range.length == length() { + if range.location == 0 && range.length == length() { removeFromParent() } else { let childrenAndIntersections = childNodes(intersectingRange: range) @@ -1282,65 +1307,14 @@ extension Libxml2 { element.insert(string, atNodeIndex: insertionIndex) } - override func replaceCharacters(inRange range: NSRange, withString string: String, preferLeftNode: Bool = true) { - let childrenAndIntersections = childNodes(intersectingRange: range) - let preferRightNode = !preferLeftNode - var textInserted = false - - assert(range.location == 0 || childrenAndIntersections.count > 0) + override func replaceCharacters(inRange range: NSRange, withString string: String) { - guard childrenAndIntersections.count > 0 else { - insert(string, atLocation: 0) - return + if range.length > 0 { + deleteCharacters(inRange: range) } - for (index, childAndIntersection) in childrenAndIntersections.enumerated() { - let child = childAndIntersection.child - let intersection = childAndIntersection.intersection - - guard !textInserted else { - child.deleteCharacters(inRange: intersection) - continue - } - - if intersection.location == 0 { - guard index == 0 || preferRightNode else { - if intersection.length > 0 { - child.deleteCharacters(inRange: intersection) - } - continue - } - - if preferLeftNode || mustInterruptStyleAtEdges(forNode: child) { - let childIndex = indexOf(childNode: child) - - child.deleteCharacters(inRange: intersection) - insert(string, atNodeIndex: childIndex) - } else { - child.replaceCharacters(inRange: intersection, withString: string, preferLeftNode: preferLeftNode) - } - } else if intersection.location + intersection.length == child.length() { - guard index == childrenAndIntersections.count - 1 || preferLeftNode else { - if intersection.length > 0 { - child.deleteCharacters(inRange: intersection) - } - continue - } - - if preferRightNode || mustInterruptStyleAtEdges(forNode: child) { - let childIndex = indexOf(childNode: child) + 1 - - child.deleteCharacters(inRange: intersection) - insert(string, atNodeIndex: childIndex) - } else { - child.replaceCharacters(inRange: intersection, withString: string, preferLeftNode: preferLeftNode) - } - } else { - child.replaceCharacters(inRange: intersection, withString: string, preferLeftNode: preferLeftNode) - } - - textInserted = true - } + insert(string, atLocation: range.location) + return } /// Replace characters in targetRange by a node with the name in nodeName and attributes @@ -1679,6 +1653,14 @@ extension Libxml2 { // MARK: - Overriden Methods + override func deleteCharacters(inRange range: NSRange) { + let childrenAndIntersections = childNodes(intersectingRange: range) + + for (child, intersection) in childrenAndIntersections { + child.deleteCharacters(inRange: intersection) + } + } + override func isSupportedByEditor() -> Bool { return true } diff --git a/Aztec/Classes/Libxml2/DOM/Data/Node.swift b/Aztec/Classes/Libxml2/DOM/Data/Node.swift index 14b1f1b9e..57b11d7e8 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/Node.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/Node.swift @@ -195,7 +195,7 @@ extension Libxml2 { /// - range: the range of the original string to replace. /// - string: the new string to replace the original text with. /// - func replaceCharacters(inRange range: NSRange, withString string: String, preferLeftNode: Bool) { + func replaceCharacters(inRange range: NSRange, withString string: String) { assertionFailure("This method should always be overridden.") } diff --git a/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift b/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift index fb382d116..7f247b1df 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift @@ -267,7 +267,7 @@ extension Libxml2 { return hasAncestor(ofType: .pre) } - override func replaceCharacters(inRange range: NSRange, withString string: String, preferLeftNode: Bool) { + override func replaceCharacters(inRange range: NSRange, withString string: String) { guard shouldSanitizeText() else { replaceCharacters(inRange: range, withSanitizedString: string) return diff --git a/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift b/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift index bb51d292a..f2bf339c8 100644 --- a/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift +++ b/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift @@ -107,13 +107,6 @@ extension Libxml2 { let firstChild = childNodesAndRanges[0].child let firstChildIntersection = childNodesAndRanges[0].intersection - if childNodesAndRanges.count == 1, - let elementNode = firstChild as? ElementNode { - - forceWrapChildren(of: elementNode, intersecting: firstChildIntersection, inElement: elementDescriptor) - return - } - if !NSEqualRanges(firstChild.range(), firstChildIntersection) { firstChild.split(forRange: firstChildIntersection) } diff --git a/Aztec/Classes/Libxml2/DOMString.swift b/Aztec/Classes/Libxml2/DOMString.swift index b76732ca5..2f45f01af 100644 --- a/Aztec/Classes/Libxml2/DOMString.swift +++ b/Aztec/Classes/Libxml2/DOMString.swift @@ -158,13 +158,13 @@ extension Libxml2 { /// - range: the range of the original string to replace. /// - string: the new string to replace the original text with. /// - func replaceCharacters(inRange range: NSRange, withString string: String, preferLeftNode: Bool) { + func replaceCharacters(inRange range: NSRange, withString string: String) { let domHasModifications = range.length > 0 || !string.isEmpty if domHasModifications { performAsyncUndoable { [weak self] in - self?.replaceCharactersSynchronously(inRange: range, withString: string, preferLeftNode: preferLeftNode) + self?.replaceCharactersSynchronously(inRange: range, withString: string) } } } @@ -186,8 +186,8 @@ extension Libxml2 { /// - range: the range of the original string to replace. /// - string: the new string to replace the original text with. /// - private func replaceCharactersSynchronously(inRange range: NSRange, withString string: String, preferLeftNode: Bool) { - rootNode.replaceCharacters(inRange: range, withString: string, preferLeftNode: preferLeftNode) + private func replaceCharactersSynchronously(inRange range: NSRange, withString string: String) { + rootNode.replaceCharacters(inRange: range, withString: string) } // MARK: - Undo Manager diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index 16b5d8596..eff8e5ea1 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -149,12 +149,6 @@ open class TextStorage: NSTextStorage { // MARK: - NSAttributedString preprocessing - private func preprocessStringForInsertion(_ attributedString: NSAttributedString) -> NSAttributedString { - let stringWithFixedNewlines = NSAttributedString(with: attributedString, replacingOcurrencesOf: "\n", with: String(.paragraphSeparator)) - - return preprocessAttributesForInsertion(stringWithFixedNewlines) - } - private func preprocessAttributesForInsertion(_ attributedString: NSAttributedString) -> NSAttributedString { let stringWithAttachments = preprocessAttachmentsForInsertion(attributedString) let stringWithParagraphs = preprocessParagraphsForInsertion(stringWithAttachments) @@ -263,8 +257,9 @@ open class TextStorage: NSTextStorage { override open func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [String : Any] { return textStore.length == 0 ? [:] : textStore.attributes(at: location, effectiveRange: range) } - + override open func replaceCharacters(in range: NSRange, with str: String) { + beginEditing() if mustUpdateDOM() { @@ -281,7 +276,7 @@ open class TextStorage: NSTextStorage { override open func replaceCharacters(in range: NSRange, with attrString: NSAttributedString) { - let preprocessedString = preprocessStringForInsertion(attrString) + let preprocessedString = preprocessAttributesForInsertion(attrString) beginEditing() @@ -299,7 +294,7 @@ open class TextStorage: NSTextStorage { override open func setAttributes(_ attrs: [String : Any]?, range: NSRange) { beginEditing() - if mustUpdateDOM() && allowFixingDOMAttributes, let attributes = attrs { + if mustUpdateDOM() && allowFixingDOMAttributes && range.length > 0, let attributes = attrs { applyStylesToDom(attributes: attributes, in: range) } @@ -333,9 +328,8 @@ open class TextStorage: NSTextStorage { } let targetDomRange = string.map(visualUTF16Range: swiftRange) - let preferLeftNode = doesPreferLeftNode(atCaretPosition: swiftRange.location) - dom.replaceCharacters(inRange: targetDomRange, withString: str, preferLeftNode: preferLeftNode) + dom.replaceCharacters(inRange: targetDomRange, withString: str) } private func replaceCharactersInDOM(in range: NSRange, with attrString: NSAttributedString) { @@ -344,11 +338,10 @@ open class TextStorage: NSTextStorage { } let targetDomRange = string.map(visualUTF16Range: swiftRange) - let preferLeftNode = doesPreferLeftNode(atCaretPosition: swiftRange.location) let domString = NSAttributedString(with: attrString, replacingOcurrencesOf: String(.paragraphSeparator), with: "") - dom.replaceCharacters(inRange: targetDomRange, withString: domString.string, preferLeftNode: preferLeftNode) + dom.replaceCharacters(inRange: targetDomRange, withString: domString.string) if targetDomRange.length != swiftRange.length { dom.deleteBlockSeparator(at: targetDomRange.location) @@ -375,6 +368,10 @@ open class TextStorage: NSTextStorage { let domRange = textStore.string.map(visualUTF16Range: subRange) + guard domRange.length > 0 else { + return + } + processAttributesDifference(in: domRange, key: key, sourceValue: sourceValue, targetValue: targetValue) }) } @@ -391,7 +388,7 @@ open class TextStorage: NSTextStorage { /// It's the offset this method will use to apply the styles found in the source string. /// private func applyStylesToDom(from attributedString: NSAttributedString, startingAt location: Int) { - let originalAttributes = location < textStore.length ? textStore.attributes(at: location, effectiveRange: nil) : [:] + let originalAttributes = [String:Any]() let fullRange = NSRange(location: 0, length: attributedString.length) let location = textStore.string.map(visualRange: NSRange(location: location, length: 0)).location @@ -754,69 +751,9 @@ open class TextStorage: NSTextStorage { dom.removeLink(spanning: range) } } - - - // MARK: - Range Mapping: Visual vs HTML - - private func canAppendToNodeRepresentedByCharacter(atIndex index: Int) -> Bool { - return !hasNewLine(at: index) - && !hasHorizontalLine(at: index) - && !hasCommentMarker(at: index) - && !hasUnknownHtmlMarker(at: index) - && !hasParagraphSeparator(at: index) - } - - private func doesPreferLeftNode(atCaretPosition caretPosition: Int) -> Bool { - guard caretPosition != 0, - let previousLocation = textStore.string.location(before:caretPosition) else { - return false - } - - return canAppendToNodeRepresentedByCharacter(atIndex: previousLocation) - } - - private func hasHorizontalLine(at index: Int) -> Bool { - guard let attachment = attribute(NSAttachmentAttributeName, at: index, effectiveRange: nil) else { - return false - } - - return attachment is LineAttachment - } - - private func hasCommentMarker(at index: Int) -> Bool { - guard let attachment = attribute(NSAttachmentAttributeName, at: index, effectiveRange: nil) else { - return false - } - - return attachment is CommentAttachment - } - - private func hasUnknownHtmlMarker(at index: Int) -> Bool { - guard let attachment = attribute(NSAttachmentAttributeName, at: index, effectiveRange: nil) else { - return false - } - - return attachment is HTMLAttachment - } - - private func hasNewLine(at index: Int) -> Bool { - if index >= textStore.length || index < 0 { - return false - } - let nsString = string as NSString - return nsString.substring(from: index).hasPrefix(String(Character(.newline))) - } - - private func hasParagraphSeparator(at offset: Int) -> Bool { - let startIndex = string.index(string.startIndex, offsetBy: offset) - let endIndex = string.index(string.startIndex, offsetBy: offset + 1) - - let range = startIndex ..< endIndex - - return string.substring(with: range) == String(.paragraphSeparator) - } // MARK: - Styles: Toggling + @discardableResult func toggle(formatter: AttributeFormatter, at range: NSRange) -> NSRange { let applicationRange = formatter.applicationRange(for: range, in: self) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 397c4bde5..fba0389b1 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -290,7 +290,7 @@ open class TextView: UITextView { /// to insert a `\n` character, so that the Layout Manager immediately renders the List's new bullet /// (or Blockquote's BG). /// - ensureInsertionOfNewline(beforeInserting: text) + //ensureInsertionOfEndOfLine(beforeInserting: text) // 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. @@ -584,7 +584,7 @@ open class TextView: UITextView { /// - range: The NSRange to edit. /// open func togglePre(range: NSRange) { - ensureInsertionOfNewlineWhenEditingEdgeOfTheDocument() + ensureInsertionOfEndOfLineForEmptyParagraphAtEndOfFile() let formatter = PreFormatter(placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) @@ -601,7 +601,7 @@ open class TextView: UITextView { /// - range: The NSRange to edit. /// open func toggleBlockquote(range: NSRange) { - ensureInsertionOfNewlineWhenEditingEdgeOfTheDocument() + ensureInsertionOfEndOfLineForEmptyParagraphAtEndOfFile() let formatter = BlockquoteFormatter(placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) @@ -614,7 +614,7 @@ open class TextView: UITextView { /// - Parameter range: The NSRange to edit. /// open func toggleOrderedList(range: NSRange) { - ensureInsertionOfNewlineWhenEditingEdgeOfTheDocument() + ensureInsertionOfEndOfLineForEmptyParagraphAtEndOfFile() let formatter = TextListFormatter(style: .ordered, placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) @@ -628,7 +628,7 @@ open class TextView: UITextView { /// - Parameter range: The NSRange to edit. /// open func toggleUnorderedList(range: NSRange) { - ensureInsertionOfNewlineWhenEditingEdgeOfTheDocument() + ensureInsertionOfEndOfLineForEmptyParagraphAtEndOfFile() let formatter = TextListFormatter(style: .unordered, placeholderAttributes: typingAttributes) toggle(formatter: formatter, atRange: range) @@ -713,18 +713,25 @@ open class TextView: UITextView { } - /// Inserts an empty line whenever we're at the end of the document + /// Inserts an end-of-line character whenever we're at end-of-file, in an + /// empty paragraph. This is useful when attempting to apply a paragraph-level style at EOF, + /// since it won't be possible without the paragraph having any characters. /// - private func ensureInsertionOfNewlineWhenEditingEdgeOfTheDocument() { - guard selectedRange.location == storage.length else { + /// Call this method before applying the formatter. + /// + private func ensureInsertionOfEndOfLineForEmptyParagraphAtEndOfFile() { + + guard let selectedRangeForSwift = textStorage.string.nsRange(fromUTF16NSRange: selectedRange) else { + assertionFailure("This should never happen. Review the logic!") return } - insertNewline() + if textStorage.string.isEmptyParagraph(at: selectedRangeForSwift.location) { + insertEndOfLineCharacter() + } } - - /// Inserts an empty line whenever: + /// Inserts an end-of-line chracter whenever: /// /// A. We're about to insert a new line /// B. We're at the end of the document @@ -733,7 +740,7 @@ open class TextView: UITextView { /// We're doing this as a workaround, in order to force the LayoutManager render the Bullet (OR) /// Blockquote's background. /// - private func ensureInsertionOfNewline(beforeInserting text: String) { + private func ensureInsertionOfEndOfLine(beforeInserting text: String) { guard text == String(.newline) else { return } @@ -757,13 +764,13 @@ open class TextView: UITextView { return } - insertNewline() + insertEndOfLineCharacter() } - - /// Inserts a New Line at the current position, while retaining the selectedRange and typingAttributes. + /// Inserts a end-of-line character at the current position, while retaining the selectedRange + /// and typingAttributes. /// - private func insertNewline() { + private func insertEndOfLineCharacter() { let previousRange = selectedRange let previousStyle = typingAttributes From 1005dd7df7da55c860900a46569b6b8a69850d71 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 04:23:41 -0300 Subject: [PATCH 06/25] Improves the editing logic. --- .../Libxml2/DOM/Data/ElementNode.swift | 51 ++++--------------- Aztec/Classes/TextKit/TextStorage.swift | 8 ++- 2 files changed, 15 insertions(+), 44 deletions(-) diff --git a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift index df8245fc5..08d1fa625 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift @@ -1261,50 +1261,17 @@ extension Libxml2 { /// func insert(_ string: String, atLocation location: Int) { - let blockLevelElementsAndIntersections = lowestBlockLevelElements(intersectingRange: NSRange(location: location, length: 0)) - - guard blockLevelElementsAndIntersections.count != 0 else { - if location == 0 { - // It's not great having to set empty text and then append text to it. The reason - // we're doing it here is that if the text contains line-breaks, they will only - // be processed as BR tags if the text is set after construction. - // - // This code can be improved but this "hack" will allow us to postpone the necessary - // code restructuration. - // - let textNode = TextNode(text: "", editContext: editContext) - append(textNode) - textNode.append(string) - } else { - fatalError("If there are no child nodes, the insert location has to be zero.") - } + let textNode = TextNode(text: "", editContext: editContext) + let childrenBefore = splitChildren(before: location) + insert(textNode, at: childrenBefore.count) - return - } + // WORKAROUND: For the time being we need to append the text to properly parse
nodes. + // It's also important to do this AFTER the node has been inserted in the DOM since it + // needs its parent to be set. + // + textNode.append(string) - let element = blockLevelElementsAndIntersections[0].element - let intersection = blockLevelElementsAndIntersections[0].intersection - - let indexAndIntersection = element.indexOf(childNodeIntersecting: intersection.location) - - let childIndex = indexAndIntersection.index - let childIntersection = indexAndIntersection.intersection - - let child = element.children[childIndex] - var insertionIndex: Int - - if childIntersection == 0 { - insertionIndex = childIndex - } else { - - if childIntersection < child.length() { - child.split(atLocation: childIntersection) - } - - insertionIndex = childIndex + 1 - } - - element.insert(string, atNodeIndex: insertionIndex) + return } override func replaceCharacters(inRange range: NSRange, withString string: String) { diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index eff8e5ea1..f70b454e9 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -329,7 +329,9 @@ open class TextStorage: NSTextStorage { let targetDomRange = string.map(visualUTF16Range: swiftRange) - dom.replaceCharacters(inRange: targetDomRange, withString: str) + if targetDomRange.length > 0 || str.characters.count > 0 { + dom.replaceCharacters(inRange: targetDomRange, withString: str) + } } private func replaceCharactersInDOM(in range: NSRange, with attrString: NSAttributedString) { @@ -341,7 +343,9 @@ open class TextStorage: NSTextStorage { let domString = NSAttributedString(with: attrString, replacingOcurrencesOf: String(.paragraphSeparator), with: "") - dom.replaceCharacters(inRange: targetDomRange, withString: domString.string) + if targetDomRange.length > 0 || domString.length > 0 { + dom.replaceCharacters(inRange: targetDomRange, withString: domString.string) + } if targetDomRange.length != swiftRange.length { dom.deleteBlockSeparator(at: targetDomRange.location) From fe607a3e5099a1c8e3ba40b7759e013ca5a8edbc Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 04:31:26 -0300 Subject: [PATCH 07/25] Fixes a problem with some extra newlines being automatically added. --- Aztec/Classes/TextKit/TextView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index fba0389b1..6f72367bf 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -726,7 +726,7 @@ open class TextView: UITextView { return } - if textStorage.string.isEmptyParagraph(at: selectedRangeForSwift.location) { + if selectedRangeForSwift.location == textStorage.length { insertEndOfLineCharacter() } } From b9bfd20fc59d07f5d66a34d2e3c038f215e7c9ce Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 04:33:44 -0300 Subject: [PATCH 08/25] Fixes a previous faulty fix. :) --- Aztec/Classes/TextKit/TextView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 6f72367bf..22528b2e8 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -726,7 +726,9 @@ open class TextView: UITextView { return } - if selectedRangeForSwift.location == textStorage.length { + if selectedRangeForSwift.location == textStorage.length + && textStorage.string.isEmptyParagraph(at: selectedRangeForSwift.location) { + insertEndOfLineCharacter() } } From 53953d3521ca9b683889464cc1d7027f97d73585 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 05:12:18 -0300 Subject: [PATCH 09/25] Removes a lot of unnecessary code. --- .../Classes/Extensions/String+EndOfLine.swift | 12 ++ Aztec/Classes/TextKit/TextView.swift | 119 ++++-------------- 2 files changed, 38 insertions(+), 93 deletions(-) diff --git a/Aztec/Classes/Extensions/String+EndOfLine.swift b/Aztec/Classes/Extensions/String+EndOfLine.swift index 1dddcb1fa..381638dc3 100644 --- a/Aztec/Classes/Extensions/String+EndOfLine.swift +++ b/Aztec/Classes/Extensions/String+EndOfLine.swift @@ -27,6 +27,18 @@ extension String { return isEmptyParagraph(at: index) } + /// Checks if the receiver has an empty paragraph at the specified offset and if the offset + /// corresponds to EOF (end-of-file). + /// + /// - Parameters: + /// - offset: the receiver's offset to check + /// + /// - Returns: `true` if the specified offset is in an empty paragraph, `false` otherwise. + /// + func isEmptyParagraphAtEndOfFile(at offset: Int) -> Bool { + return offset == characters.count && isEmptyParagraph(at: offset) + } + /// This methods verifies if the receiver string is an end-of-line character. /// /// - Returns: `true` if the receiver is an end-of-line character. diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 22528b2e8..de178d94c 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -213,21 +213,23 @@ 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, Pre] attributes. + + /// This is currently triggered when the text selection changes, which makes it great for + /// updating typingAttributes for selection changes only (and not for text insertion). + /// /// For details, see: https://github.com/wordpress-mobile/AztecEditor-iOS/issues/414 /// - override open var typingAttributes: [String: Any] { - get { - ensureRemovalOfParagraphAttributes(at: selectedRange) - return super.typingAttributes - } + open override var selectedTextRange: UITextRange? { set { - super.typingAttributes = newValue + super.selectedTextRange = newValue + + ensureRemovalOfSingleLineParagraphAttributesAfterSelectionChange() } - } + get { + return super.selectedTextRange + } + } // MARK: - Intercept copy paste operations @@ -286,27 +288,6 @@ 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, 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). - /// - //ensureInsertionOfEndOfLine(beforeInserting: text) - - // 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. - // But because we don't call the super we need to refresh the attributes ourselfs, and callback to the delegate. - // - if ensureRemovalOfParagraphAttributes(beforeInserting: text, at: selectedRange) { - if self.textStorage.length > 0 { - typingAttributes = textStorage.attributes(at: min(selectedRange.location, textStorage.length-1), effectiveRange: nil) - } - - delegate?.textViewDidChangeSelection?(self) - delegate?.textViewDidChange?(self) - - return - } - // Emoji Fix: // Fallback to the default font, whenever the Active Font's Family doesn't match with the Default Font's family. // We do this twice (before and after inserting text), in order to properly handle two scenarios: @@ -326,8 +307,6 @@ open class TextView: UITextView { restoreDefaultFontIfNeeded() - ensureRemovalOfSingleLineParagraphAttributes(insertedText: text, at: selectedRange) - ensureCursorRedraw(afterEditing: text) } @@ -813,30 +792,6 @@ open class TextView: UITextView { HeaderFormatter(headerLevel:.h5), HeaderFormatter(headerLevel:.h6), ] - /// This helper will proceed to remove the Paragraph attributes when a new line is inserted at the end of an paragraph. - /// Examples of this are the header attributes (Heading 1 to 6) When you start a new paragraph it shoudl reset to the standard style. - /// - /// - Parameters: - /// - insertedText: String that just got inserted. - /// - at: Range in which the string was inserted. - /// - /// - Returns: True if ParagraphAttributes were removed. False otherwise! - /// - @discardableResult func ensureRemovalOfSingleLineParagraphAttributes(insertedText text: String, at range: NSRange) -> Bool { - - guard textStorage.string.isEmptyParagraph(at: range.location) else { - return false - } - - for formatter in formattersThatBreakAfterEnter { - if formatter.present(in: typingAttributes) { - typingAttributes = formatter.remove(from: typingAttributes) - return true - } - } - - return false - } /// Force the SDK to Redraw the cursor, asynchronously, if the edited text (inserted / deleted) requires it. @@ -1158,7 +1113,9 @@ open class TextView: UITextView { // private extension TextView { - /// Removes the Paragraph Attributes whenever `mustRemoveParagraphAttributes(beforeInserting: at)` returns true. + /// Removes single-line paragraph attributes after a selection change. + /// The logic that defines if the attributes must be removes is located in + /// `mustRemoveSingleLineParagraphAttributesAfterSelectionChange()`. /// /// - Parameters: /// - text: (Optional) String that's about to be inserted. @@ -1167,47 +1124,23 @@ private extension TextView { /// - Returns: true if ParagraphAttributes were removed. false otherwise! /// @discardableResult - func ensureRemovalOfParagraphAttributes(beforeInserting text: String? = nil, at selectedRange: NSRange) -> Bool { - guard mustRemoveParagraphAttributes(beforeInserting: text, at: selectedRange.location) else { + func ensureRemovalOfSingleLineParagraphAttributesAfterSelectionChange() -> Bool { + guard mustRemoveSingleLineParagraphAttributesAfterSelectionChange() else { return false } - return removeParagraphAttributes(at: selectedRange) + return removeSingleLineParagraphAttributes(at: selectedRange) } - - /// Analyzes whether the Paragraph Attributes should be removed at a specified location, or not. - /// This is necessary in two different scenarios: - /// - /// Scenario A: + /// Analyzes whether single-line paragraph attributes should be removed from the specified + /// location, or not, after the selection range is changed. /// - /// A. We're at the end of the document - /// B. Below there's an empty line. - /// C. The user pressed Arrow Down + /// - Returns: `true` if we should remove single-line paragraph attributes, otherwise it + /// returns `false`. /// - /// Why: We only want to carry over the `Paragraph Attribute` if a Newline is explicitly pressed. - /// - /// Scenario B: - /// - /// A. The user enters a newline - /// B. The next character is a newline (OR) there is no next character - /// C. The previous character is a newline (OR) there is no previous character - /// - /// Why: We wanna take care of removing [Lists, Pre, Blockquotes] if the user hits return on an empty line. - /// - /// - /// - Parameters: - /// - text: String that is about to be inserted. - /// - location: Selected Range's Location - /// - /// - Returns: true if we should remove the paragraph attributes. false otherwise! - /// - func mustRemoveParagraphAttributes(beforeInserting text: String? = nil, at location: Int) -> Bool { - guard text?.isEndOfLine() == true || location == storage.length else { - return false - } - - return storage.string.isEmptyParagraph(at: location) + func mustRemoveSingleLineParagraphAttributesAfterSelectionChange() -> Bool { + return selectedRange.location == storage.length + && storage.string.isEmptyParagraph(at: selectedRange.location) } @@ -1216,7 +1149,7 @@ private extension TextView { /// /// - Returns: true on success. /// - func removeParagraphAttributes(at range: NSRange) -> Bool { + func removeSingleLineParagraphAttributes(at range: NSRange) -> Bool { let formatters: [AttributeFormatter] = [ BlockquoteFormatter(), PreFormatter(placeholderAttributes: defaultAttributes), From 5d5513cec476e7c0895f5de14453b2ddf4f4b35d Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 05:14:37 -0300 Subject: [PATCH 10/25] The unit tests compile again, still fixing them. --- AztecTests/HTML/DOMStringTests.swift | 4 ++-- AztecTests/HTML/ElementNodeTests.swift | 2 +- AztecTests/HTML/TextNodeTests.swift | 22 +++++++++++----------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/AztecTests/HTML/DOMStringTests.swift b/AztecTests/HTML/DOMStringTests.swift index 8489a9165..73344f43f 100644 --- a/AztecTests/HTML/DOMStringTests.swift +++ b/AztecTests/HTML/DOMStringTests.swift @@ -21,10 +21,10 @@ class DOMStringTests: XCTestCase { func testReplaceCharactersWithStringEffectivelyInsertsTheNewString() { let string = DOMString() - string.replaceCharacters(inRange: NSRange.zero, withString: "Hello\n", preferLeftNode: true) + string.replaceCharacters(inRange: NSRange.zero, withString: "Hello\n") XCTAssertEqual(string.getHTML(), "Hello
") - string.replaceCharacters(inRange: NSRange(location: 6, length: 0), withString: "World!", preferLeftNode: false) + string.replaceCharacters(inRange: NSRange(location: 6, length: 0), withString: "World!") XCTAssertEqual(string.getHTML(), "Hello
World!") } diff --git a/AztecTests/HTML/ElementNodeTests.swift b/AztecTests/HTML/ElementNodeTests.swift index dfc3c5474..5cd0ea1da 100644 --- a/AztecTests/HTML/ElementNodeTests.swift +++ b/AztecTests/HTML/ElementNodeTests.swift @@ -1214,7 +1214,7 @@ class ElementNodeTests: XCTestCase { let replaceRange = NSRange(location: text1.characters.count + space.characters.count, length: textToReplace.characters.count) - rootNode.replaceCharacters(inRange: replaceRange, withString: "everyone", preferLeftNode: true) + rootNode.replaceCharacters(inRange: replaceRange, withString: "everyone") XCTAssertEqual(rootNode.children.count, 2) XCTAssertEqual(rootNode.children[0], boldNode) diff --git a/AztecTests/HTML/TextNodeTests.swift b/AztecTests/HTML/TextNodeTests.swift index 81e8ab4c8..fcdb44561 100644 --- a/AztecTests/HTML/TextNodeTests.swift +++ b/AztecTests/HTML/TextNodeTests.swift @@ -320,7 +320,7 @@ class TextNodeTests: XCTestCase { let replaceRange = NSRange(location: 0, length: helloText.characters.count) - textNode.replaceCharacters(inRange: replaceRange, withString: byeText, preferLeftNode: true) + textNode.replaceCharacters(inRange: replaceRange, withString: byeText) XCTAssertEqual(paragraphNode.children.count, 1) XCTAssertEqual(paragraphNode.children[0], textNode) @@ -351,7 +351,7 @@ class TextNodeTests: XCTestCase { let replaceRange = NSRange(location: helloText.characters.count, length: worldText.characters.count) - textNode.replaceCharacters(inRange: replaceRange, withString: cityText, preferLeftNode: true) + textNode.replaceCharacters(inRange: replaceRange, withString: cityText) XCTAssertEqual(paragraphNode.children.count, 1) XCTAssertEqual(paragraphNode.children[0], textNode) @@ -380,7 +380,7 @@ class TextNodeTests: XCTestCase { let replaceRange = NSRange(location: 0, length: helloText.characters.count) - textNode.replaceCharacters(inRange: replaceRange, withString: helloAndBreakText, preferLeftNode: true) + textNode.replaceCharacters(inRange: replaceRange, withString: helloAndBreakText) XCTAssertEqual(paragraphNode.children.count, 3) @@ -426,7 +426,7 @@ class TextNodeTests: XCTestCase { let replaceRange = NSRange(location: helloText.characters.count, length: worldText.characters.count) - textNode.replaceCharacters(inRange: replaceRange, withString: breakAndWorldText, preferLeftNode: true) + textNode.replaceCharacters(inRange: replaceRange, withString: breakAndWorldText) XCTAssertEqual(paragraphNode.children.count, 3) @@ -474,7 +474,7 @@ class TextNodeTests: XCTestCase { let replaceRange = NSRange(location: helloText.characters.count, length: space.characters.count) - textNode.replaceCharacters(inRange: replaceRange, withString: breakText, preferLeftNode: true) + textNode.replaceCharacters(inRange: replaceRange, withString: breakText) XCTAssertEqual(paragraphNode.children.count, 3) @@ -522,7 +522,7 @@ class TextNodeTests: XCTestCase { let replaceRange = NSRange(location: helloText.characters.count, length: space.characters.count) - textNode.replaceCharacters(inRange: replaceRange, withString: newText, preferLeftNode: true) + textNode.replaceCharacters(inRange: replaceRange, withString: newText) XCTAssertEqual(paragraphNode.children.count, 5) @@ -830,7 +830,7 @@ class TextNodeTests: XCTestCase { let textNode = TextNode(text: fullText, editContext: editContext) - textNode.replaceCharacters(inRange: range, withString: newText, preferLeftNode: true) + textNode.replaceCharacters(inRange: range, withString: newText) XCTAssertEqual(textNode.text(), newFullText) undoManager.undo() @@ -865,7 +865,7 @@ class TextNodeTests: XCTestCase { let textNode = TextNode(text: fullText, editContext: editContext) - textNode.replaceCharacters(inRange: range, withString: newText, preferLeftNode: true) + textNode.replaceCharacters(inRange: range, withString: newText) XCTAssertEqual(textNode.text(), newFullText) undoManager.undo() @@ -900,7 +900,7 @@ class TextNodeTests: XCTestCase { let textNode = TextNode(text: fullText, editContext: editContext) - textNode.replaceCharacters(inRange: range, withString: newText, preferLeftNode: true) + textNode.replaceCharacters(inRange: range, withString: newText) XCTAssertEqual(textNode.text(), newFullText) undoManager.undo() @@ -934,7 +934,7 @@ class TextNodeTests: XCTestCase { let textNode = TextNode(text: fullText, editContext: editContext) - textNode.replaceCharacters(inRange: range, withString: newText, preferLeftNode: true) + textNode.replaceCharacters(inRange: range, withString: newText) XCTAssertEqual(textNode.text(), newText) undoManager.undo() @@ -968,7 +968,7 @@ class TextNodeTests: XCTestCase { let textNode = TextNode(text: fullText, editContext: editContext) - textNode.replaceCharacters(inRange: range, withString: newText, preferLeftNode: true) + textNode.replaceCharacters(inRange: range, withString: newText) XCTAssertEqual(textNode.text(), newText) undoManager.undo() From f16292b4be18979fdd5b44b1ba8a0617bfcb00cd Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 06:49:54 -0300 Subject: [PATCH 11/25] Fixes several tests and improves the logic. --- .../Libxml2/DOM/Data/ElementNode.swift | 77 +++++++++++++++---- Aztec/Classes/Libxml2/DOM/Data/TextNode.swift | 4 +- AztecTests/HTML/DOMEditorTests.swift | 21 +++-- AztecTests/HTML/ElementNodeTests.swift | 15 ++-- 4 files changed, 80 insertions(+), 37 deletions(-) diff --git a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift index 08d1fa625..1bd58bfc1 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift @@ -689,7 +689,6 @@ extension Libxml2 { /// /// - Returns: the requested sibling, or `nil` if there's none. /// - func sibling(leftOf childIndex: Int) -> T? { guard childIndex >= 0 && childIndex < children.count else { @@ -718,7 +717,6 @@ extension Libxml2 { /// /// - Returns: the requested sibling, or `nil` if there's none. /// - func sibling(rightOf childIndex: Int) -> T? { guard childIndex >= 0 && childIndex < children.count else { @@ -887,11 +885,70 @@ extension Libxml2 { /// - Parameters: /// - child: the node to insert. /// - index: the position where to insert the node. + /// - mergeSiblings: if true, this method will attempt to merge the inserted node with + /// similar siblings. /// - func insert(_ child: Node, at index: Int) { + func insert(_ child: Node, at index: Int, tryToMergeWithSiblings: Bool = true) { child.removeFromParent() - children.insert(child, at: index) - child.parent = self + + if tryToMergeWithSiblings && index > 0, + let previousChild = children[index - 1] as? TextNode, + let newChildTextNode = child as? TextNode { + + previousChild.append(newChildTextNode.text()) + } else if tryToMergeWithSiblings && index < children.count, + let nextChild = children[index] as? TextNode, + let newChildTextNode = child as? TextNode { + + nextChild.prepend(newChildTextNode.text()) + } else { + children.insert(child, at: index) + child.parent = self + } + } + + /// Prepends children to the list of children for this element. + /// + /// - Parameters: + /// - children: the nodes to prepend. + /// + func insert(_ children: [Node], at index: Int) { + for child in children.reversed() { + insert(child, at: index, tryToMergeWithSiblings: false) + } + + fixChildrenTextNodes() + } + + func fixChildrenTextNodes() { + for child in children { + let index = indexOf(childNode: child) + let nextIndex = index + 1 + + if nextIndex < children.count, + let currentTextNode = child as? TextNode, + let nextTextNode = children[nextIndex] as? TextNode { + + nextTextNode.prepend(currentTextNode.text()) + remove(currentTextNode) + } + } + } + + func nodesRepresenting(_ string: String) -> [Node] { + let separatorElement = ElementNodeDescriptor(elementType: .br) + let components = string.components(separatedBy: String(.newline)) + var nodes = [Node]() + + for (index, component) in components.enumerated() { + nodes.append(TextNode(text: component, editContext: editContext)) + + if index != components.count - 1 { + nodes.append(ElementNode(descriptor: separatorElement, children: [], editContext: editContext)) + } + } + + return nodes } /// Replaces the specified node with several new nodes. @@ -1261,15 +1318,9 @@ extension Libxml2 { /// func insert(_ string: String, atLocation location: Int) { - let textNode = TextNode(text: "", editContext: editContext) + let nodesToInsert = nodesRepresenting(string) let childrenBefore = splitChildren(before: location) - insert(textNode, at: childrenBefore.count) - - // WORKAROUND: For the time being we need to append the text to properly parse
nodes. - // It's also important to do this AFTER the node has been inserted in the DOM since it - // needs its parent to be set. - // - textNode.append(string) + insert(nodesToInsert, at: childrenBefore.count) return } diff --git a/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift b/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift index 7f247b1df..1e2ab5496 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift @@ -329,14 +329,14 @@ extension Libxml2 { let newNode = TextNode(text: contents.substring(with: postRange), editContext: editContext) deleteCharacters(inRange: postRange) - parent.insert(newNode, at: nodeIndex + 1) + parent.insert(newNode, at: nodeIndex + 1, tryToMergeWithSiblings: false) } if !preRange.isEmpty { let newNode = TextNode(text: contents.substring(with: preRange), editContext: editContext) deleteCharacters(inRange: preRange) - parent.insert(newNode, at: nodeIndex) + parent.insert(newNode, at: nodeIndex, tryToMergeWithSiblings: false) } } diff --git a/AztecTests/HTML/DOMEditorTests.swift b/AztecTests/HTML/DOMEditorTests.swift index 00af0c9b1..ccf185db0 100644 --- a/AztecTests/HTML/DOMEditorTests.swift +++ b/AztecTests/HTML/DOMEditorTests.swift @@ -22,8 +22,7 @@ class DOMEditorTests: XCTestCase { let text2 = " there!" let fullText = "\(text1)\(text2)" let textNode = TextNode(text: fullText) - let paragraph = ElementNode(name: "p", attributes: [], children: [textNode]) - let rootNode = RootNode(children: [paragraph]) + let rootNode = RootNode(children: [textNode]) let editor = DOMEditor(with: rootNode) @@ -32,16 +31,16 @@ class DOMEditorTests: XCTestCase { let boldElementDescriptor = ElementNodeDescriptor(elementType: .b) editor.forceWrap(range: wrapRange, inElement: boldElementDescriptor) - XCTAssertEqual(paragraph.children.count, 2) + XCTAssertEqual(rootNode.children.count, 2) - guard let newBoldNode = paragraph.children[0] as? ElementNode, newBoldNode.name == "b" else { + guard let newBoldNode = rootNode.children[0] as? ElementNode, newBoldNode.name == "b" else { XCTFail("Expected a bold node.") return } XCTAssertEqual(newBoldNode.text(), text1) - guard let newTextNode = paragraph.children[1] as? TextNode else { + guard let newTextNode = rootNode.children[1] as? TextNode else { XCTFail("Expected a text node.") return } @@ -292,14 +291,14 @@ class DOMEditorTests: XCTestCase { XCTAssertEqual(boldNode.text(), newBoldNode.text()) } - /// Tests that `findSiblings(separatedAt:)` works properly. + /// Tests that `mergeBlockLevelElementRight(endingAt:)` works properly. /// /// - Input: /// - HTML: "

Hello

world!
" - /// - Separation location: 4 + /// - Separation location: length of "Hello" /// /// - Expected results: - /// - Both the bold and italic nodes should be returned. + /// - HTML: "

Helloworld!

" /// func testMergeSiblings() { let text1 = "Hello" @@ -314,7 +313,6 @@ class DOMEditorTests: XCTestCase { let editor = DOMEditor(with: rootNode) - //editor.mergeSiblings(separatedAt: textNode1.length()) editor.mergeBlockLevelElementRight(endingAt: textNode1.length()) XCTAssertEqual(rootNode.children.count, 1) @@ -338,10 +336,9 @@ class DOMEditorTests: XCTestCase { /// /// XCTAssertEqual(newTextNode.text(), "\(text1)\(text2)") - XCTAssertEqual(newParagraph.children.count, 2) + XCTAssertEqual(newParagraph.children.count, 1) XCTAssert(newParagraph.children[0] is TextNode) - XCTAssert(newParagraph.children[1] is TextNode) - XCTAssertEqual(newParagraph.text(), "\(text1)\(text2)") + XCTAssertEqual(newParagraph.children[0].text(), "\(text1)\(text2)") } /// Tests that `findSiblings(separatedAt:)` works properly. diff --git a/AztecTests/HTML/ElementNodeTests.swift b/AztecTests/HTML/ElementNodeTests.swift index 5cd0ea1da..b818437e9 100644 --- a/AztecTests/HTML/ElementNodeTests.swift +++ b/AztecTests/HTML/ElementNodeTests.swift @@ -27,14 +27,11 @@ class ElementNodeTests: XCTestCase { /// Tests that `prepend(_ child:)` works. /// /// Inputs: - /// - Original node contents: "Hello there!" - /// - Range: (loc: 6, len: 6) - /// - New string: "-" + /// - HTML: " world!" + /// - String to prepend: "Hello" /// /// Verifications: - /// - Check that the undo event is properly registered. - /// - Check that after editing the text node, its content is: "Hello -" - /// - Check that after undoing the text node edit, its content is: "Hello there!" + /// - HTML: "Hello world! /// func testPrepend() { let text1 = "Hello" @@ -51,9 +48,8 @@ class ElementNodeTests: XCTestCase { boldNode.prepend(textNode1) - XCTAssertEqual(boldNode.children.count, 2) - XCTAssertEqual(boldNode.children[0], textNode1) - XCTAssertEqual(boldNode.children[1], textNode2) + XCTAssertEqual(boldNode.children.count, 1) + XCTAssertEqual(boldNode.children[0].text(), fullText) XCTAssertEqual(boldNode.text(), fullText) } @@ -1150,7 +1146,6 @@ class ElementNodeTests: XCTestCase { /// Input HTML: `

Click on this link

` /// - Range: the range of the full contents of the `` node. /// - New String: "link!" - /// - Inherit Style: false /// /// Expected results: /// - Output: `

Click on this link!

` From 92a8f766b4b3e4382c65fee5b3b5d3ec7a4fbbe3 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 08:04:37 -0300 Subject: [PATCH 12/25] Fixes the logic for the removal of single-line attributes. --- Aztec/Classes/Libxml2/DOM/Data/TextNode.swift | 14 +- .../Classes/Libxml2/DOM/Logic/DOMEditor.swift | 4 +- Aztec/Classes/TextKit/TextView.swift | 137 ++++++++++++------ AztecTests/HTML/DOMEditorTests.swift | 117 +-------------- 4 files changed, 108 insertions(+), 164 deletions(-) diff --git a/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift b/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift index 1e2ab5496..507c054be 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift @@ -111,8 +111,8 @@ extension Libxml2 { let textNode = TextNode(text: component, editContext: editContext) let separator = ElementNode(descriptor: separatorDescriptor) - parent.insert(textNode, at: insertionIndex) - parent.insert(separator, at: insertionIndex + 1) + parent.insert(textNode, at: insertionIndex, tryToMergeWithSiblings: false) + parent.insert(separator, at: insertionIndex + 1, tryToMergeWithSiblings: false) insertionIndex = insertionIndex + 2 } @@ -191,7 +191,7 @@ extension Libxml2 { let separator = ElementNode(descriptor: separatorDescriptor) - parent.insert(separator, at: insertionIndex) + parent.insert(separator, at: insertionIndex, tryToMergeWithSiblings: false) insertionIndex = insertionIndex + 1 } else if index == components.count - 1 { rightNode.prepend(sanitizedString: component) @@ -199,12 +199,14 @@ extension Libxml2 { let textNode = TextNode(text: component, editContext: editContext) let separator = ElementNode(descriptor: separatorDescriptor) - parent.insert(textNode, at: insertionIndex) - parent.insert(separator, at: insertionIndex + 1) + parent.insert(textNode, at: insertionIndex, tryToMergeWithSiblings: false) + parent.insert(separator, at: insertionIndex + 1, tryToMergeWithSiblings: false) insertionIndex = insertionIndex + 2 } } + + parent.fixChildrenTextNodes() } } @@ -308,7 +310,7 @@ extension Libxml2 { let newNode = TextNode(text: text().substring(with: postRange), editContext: editContext) deleteCharacters(inRange: postRange) - parent.insert(newNode, at: nodeIndex + 1) + parent.insert(newNode, at: nodeIndex + 1, tryToMergeWithSiblings: false) } } diff --git a/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift b/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift index f2bf339c8..a140a672e 100644 --- a/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift +++ b/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift @@ -53,7 +53,7 @@ extension Libxml2 { /// - targetRange: the range that must be wrapped. /// - elementDescriptor: the descriptor for the element to wrap the range in. /// - func forceWrap(range targetRange: NSRange, inElement elementDescriptor: ElementNodeDescriptor) { + private func forceWrap(range targetRange: NSRange, inElement elementDescriptor: ElementNodeDescriptor) { forceWrap(element: rootNode, range: targetRange, inElement: elementDescriptor) } @@ -68,7 +68,7 @@ extension Libxml2 { /// - targetRange: the range that must be wrapped. /// - elementDescriptor: the descriptor for the element to wrap the range in. /// - func forceWrap(element: ElementNode, range targetRange: NSRange, inElement elementDescriptor: ElementNodeDescriptor) { + private func forceWrap(element: ElementNode, range targetRange: NSRange, inElement elementDescriptor: ElementNodeDescriptor) { if NSEqualRanges(targetRange, element.range()) && canWrap(node: element, in: elementDescriptor) { diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index de178d94c..b8898cf56 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -149,6 +149,25 @@ open class TextView: UITextView { return textStorage as! TextStorage } + // MARK: - Overwritten Properties + + /// This is currently triggered when the text selection changes, which makes it great for + /// updating typingAttributes for selection changes only (and not for text insertion). + /// + /// For details, see: https://github.com/wordpress-mobile/AztecEditor-iOS/issues/414 + /// + open override var selectedTextRange: UITextRange? { + set { + super.selectedTextRange = newValue + + ensureRemovalOfParagraphAttributesAfterSelectionChange() + } + + get { + return super.selectedTextRange + } + } + // MARK: - Init & deinit public init(defaultFont: UIFont, defaultMissingImage: UIImage) { @@ -211,26 +230,6 @@ open class TextView: UITextView { addGestureRecognizer(attachmentGestureRecognizer) } - - // MARK: - Overwritten Properties - - /// This is currently triggered when the text selection changes, which makes it great for - /// updating typingAttributes for selection changes only (and not for text insertion). - /// - /// For details, see: https://github.com/wordpress-mobile/AztecEditor-iOS/issues/414 - /// - open override var selectedTextRange: UITextRange? { - set { - super.selectedTextRange = newValue - - ensureRemovalOfSingleLineParagraphAttributesAfterSelectionChange() - } - - get { - return super.selectedTextRange - } - } - // MARK: - Intercept copy paste operations open override func cut(_ sender: Any?) { @@ -305,6 +304,8 @@ open class TextView: UITextView { super.insertText(text) + ensureRemovalOfSingleLineParagraphAttributes(afterInserting: text) + restoreDefaultFontIfNeeded() ensureCursorRedraw(afterEditing: text) @@ -1109,36 +1110,85 @@ open class TextView: UITextView { } } -// MARK: - Paragraph Formatters Rendering Workarounds -// + +// MARK: - Paragraph Formatter Rendering Workarounds + private extension TextView { - /// Removes single-line paragraph attributes after a selection change. - /// The logic that defines if the attributes must be removes is located in - /// `mustRemoveSingleLineParagraphAttributesAfterSelectionChange()`. + /// Removes paragraph attributes after a selection change. The logic that defines if the + /// attributes must be removed is located in + /// `mustRemoveSingleLineParagraphAttributes()`. /// /// - Parameters: - /// - text: (Optional) String that's about to be inserted. - /// - selectedRange: TextView's Selected Range. + /// - text: the text that was just inserted into the TextView. /// - /// - Returns: true if ParagraphAttributes were removed. false otherwise! + func ensureRemovalOfSingleLineParagraphAttributes(afterInserting text: String) { + guard mustRemoveSingleLineParagraphAttributes(afterInserting: text) else { + return + } + + removeSingleLineParagraphAttributes(at: selectedRange) + } + + /// Analyzes whether paragraph attributes should be removed from the specified + /// location, or not, after the selection range is changed. /// - @discardableResult - func ensureRemovalOfSingleLineParagraphAttributesAfterSelectionChange() -> Bool { - guard mustRemoveSingleLineParagraphAttributesAfterSelectionChange() else { - return false + /// - Parameters: + /// - text: the text that was just inserted into the TextView. + /// + /// - Returns: `true` if we should remove paragraph attributes, otherwise it returns `false`. + /// + func mustRemoveSingleLineParagraphAttributes(afterInserting text: String) -> Bool { + return text.isEndOfLine() + } + + + /// Removes the Paragraph Attributes [Blockquote, Pre, Lists] at the specified range. If the range + /// is beyond the storage's contents, the typingAttributes will be modified + /// + func removeSingleLineParagraphAttributes(at range: NSRange) { + + let formatters: [AttributeFormatter] = [ + HeaderFormatter(headerLevel: .h1, placeholderAttributes: [:]), + HeaderFormatter(headerLevel: .h2, placeholderAttributes: [:]), + HeaderFormatter(headerLevel: .h3, placeholderAttributes: [:]), + HeaderFormatter(headerLevel: .h4, placeholderAttributes: [:]), + HeaderFormatter(headerLevel: .h5, placeholderAttributes: [:]), + HeaderFormatter(headerLevel: .h6, placeholderAttributes: [:]) + ] + + for formatter in formatters where formatter.present(in: super.typingAttributes) { + typingAttributes = formatter.remove(from: typingAttributes) + + let applicationRange = formatter.applicationRange(for: selectedRange, in: textStorage) + formatter.removeAttributes(from: textStorage, at: applicationRange) + } + + } +} + +// MARK: - Paragraph Formatter Rendering Workarounds + +private extension TextView { + + /// Removes paragraph attributes after a selection change. The logic that defines if the + /// attributes must be removed is located in + /// `mustRemoveSingleLineParagraphAttributesAfterSelectionChange()`. + /// + func ensureRemovalOfParagraphAttributesAfterSelectionChange() { + guard mustRemoveParagraphAttributesAfterSelectionChange() else { + return } - return removeSingleLineParagraphAttributes(at: selectedRange) + removeParagraphAttributes(at: selectedRange) } - /// Analyzes whether single-line paragraph attributes should be removed from the specified + /// Analyzes whether paragraph attributes should be removed from the specified /// location, or not, after the selection range is changed. /// - /// - Returns: `true` if we should remove single-line paragraph attributes, otherwise it - /// returns `false`. + /// - Returns: `true` if we should remove paragraph attributes, otherwise it returns `false`. /// - func mustRemoveSingleLineParagraphAttributesAfterSelectionChange() -> Bool { + func mustRemoveParagraphAttributesAfterSelectionChange() -> Bool { return selectedRange.location == storage.length && storage.string.isEmptyParagraph(at: selectedRange.location) } @@ -1147,9 +1197,7 @@ private extension TextView { /// Removes the Paragraph Attributes [Blockquote, Pre, Lists] at the specified range. If the range /// is beyond the storage's contents, the typingAttributes will be modified /// - /// - Returns: true on success. - /// - func removeSingleLineParagraphAttributes(at range: NSRange) -> Bool { + func removeParagraphAttributes(at range: NSRange) { let formatters: [AttributeFormatter] = [ BlockquoteFormatter(), PreFormatter(placeholderAttributes: defaultAttributes), @@ -1160,18 +1208,15 @@ private extension TextView { guard range.location >= storage.length else { for formatter in formatters where formatter.present(in: textStorage, at: range.location) { formatter.removeAttributes(from: textStorage, at: range) - return true + return } - return false + return } for formatter in formatters where formatter.present(in: super.typingAttributes) { - super.typingAttributes = formatter.remove(from: super.typingAttributes) - return true + typingAttributes = formatter.remove(from: typingAttributes) } - - return false } } diff --git a/AztecTests/HTML/DOMEditorTests.swift b/AztecTests/HTML/DOMEditorTests.swift index ccf185db0..aea2a89b2 100644 --- a/AztecTests/HTML/DOMEditorTests.swift +++ b/AztecTests/HTML/DOMEditorTests.swift @@ -10,109 +10,6 @@ class DOMEditorTests: XCTestCase { typealias StandardElementType = Libxml2.StandardElementType typealias TextNode = Libxml2.TextNode - /// Tests force-wrapping child nodes intersecting a certain range in a new node. - /// - /// HTML String:
Hello there!
- /// Wrap range: (0...5) - /// - /// The result should be:

Hello there!

- /// - func testForceWrapChildren() { - let text1 = "Hello" - let text2 = " there!" - let fullText = "\(text1)\(text2)" - let textNode = TextNode(text: fullText) - let rootNode = RootNode(children: [textNode]) - - let editor = DOMEditor(with: rootNode) - - let wrapRange = NSRange(location: 0, length: text1.characters.count) - - let boldElementDescriptor = ElementNodeDescriptor(elementType: .b) - editor.forceWrap(range: wrapRange, inElement: boldElementDescriptor) - - XCTAssertEqual(rootNode.children.count, 2) - - guard let newBoldNode = rootNode.children[0] as? ElementNode, newBoldNode.name == "b" else { - XCTFail("Expected a bold node.") - return - } - - XCTAssertEqual(newBoldNode.text(), text1) - - guard let newTextNode = rootNode.children[1] as? TextNode else { - XCTFail("Expected a text node.") - return - } - - XCTAssertEqual(newTextNode.text(), text2) - } - - /// Tests force-wrapping child nodes intersecting a certain range in a new node. - /// - /// HTML String:

Hello there!

- /// Wrap range: full text length - /// - /// The result should be:

Hello there!

- /// - func testForceWrapChildren2() { - let fullText = "Hello there!" - let textNode = TextNode(text: fullText) - let paragraph = ElementNode(name: "p", attributes: [], children: [textNode]) - let rootNode = RootNode(children: [paragraph]) - - let editor = DOMEditor(with: rootNode) - - let wrapRange = NSRange(location: 0, length: fullText.characters.count) - - let boldElementDescriptor = ElementNodeDescriptor(elementType: .b) - editor.forceWrap(range: wrapRange, inElement: boldElementDescriptor) - - XCTAssertEqual(paragraph.children.count, 1) - - guard let newBoldNode = paragraph.children[0] as? ElementNode, newBoldNode.name == "b" else { - XCTFail("Expected a bold node.") - return - } - - XCTAssertEqual(newBoldNode.text(), fullText) - } - - /// Tests force-wrapping child nodes intersecting a certain range in a new node. - /// - /// HTML String:
Hello there!
- /// Wrap range: (loc: 5, len: 7) - /// - /// The result should be:

Hello there!

- /// - func testForceWrapChildren3() { - let text1 = "Hello" - let text2 = " there!" - let textNode1 = TextNode(text: text1) - let textNode2 = TextNode(text: text2) - let boldNode = ElementNode(name: "b", attributes: [], children: [textNode1]) - let paragraph = ElementNode(name: "p", attributes: [], children: [boldNode, textNode2]) - let rootNode = RootNode(children: [paragraph]) - - let editor = DOMEditor(with: rootNode) - - let wrapRange = NSRange(location: text1.characters.count, length: text2.characters.count) - - let boldElementDescriptor = ElementNodeDescriptor(elementType: .b) - editor.forceWrap(range: wrapRange, inElement: boldElementDescriptor) - - XCTAssertEqual(paragraph.children.count, 1) - - guard let newBoldNode = paragraph.children[0] as? ElementNode, newBoldNode.name == "b" else { - XCTFail("Expected a bold node.") - return - } - - let fullText = "\(text1)\(text2)" - XCTAssertEqual(newBoldNode.text(), fullText) - } - - /// Tests wrapping child nodes intersecting a certain range in a new `b` node. /// /// HTML String:
Hello there!
@@ -142,20 +39,20 @@ class DOMEditorTests: XCTestCase { XCTAssertEqual(div.children.count, 2) XCTAssertEqual(div.children[1], textNode2) - guard let newEmNode = div.children[0] as? ElementNode, newEmNode.name == em.name else { - XCTFail("Expected an em node here.") + guard let newBoldNode = div.children[0] as? ElementNode, newBoldNode.name == boldElementType.rawValue else { + XCTFail("Expected a bold node here.") return } - XCTAssertEqual(newEmNode.children.count, 1) + XCTAssertEqual(newBoldNode.children.count, 1) - guard let newBoldNode = newEmNode.children[0] as? ElementNode, newBoldNode.name == boldElementType.rawValue else { - XCTFail("Expected a bold node here.") + guard let newEmNode = newBoldNode.children[0] as? ElementNode, newEmNode.name == em.name else { + XCTFail("Expected an em node here.") return } - XCTAssertEqual(newBoldNode.children.count, 1) - XCTAssertEqual(newBoldNode.children[0], textNode1) + XCTAssertEqual(newEmNode.children.count, 1) + XCTAssertEqual(newEmNode.children[0], textNode1) } From 0f54b334f460042d589a22b0530e4645b640f9a6 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 08:21:07 -0300 Subject: [PATCH 13/25] Fixes some issues. --- Aztec/Classes/TextKit/TextView.swift | 59 +++++++++++++++++++--------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index b8898cf56..fc342b886 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -287,6 +287,10 @@ open class TextView: UITextView { open override func insertText(_ text: String) { + guard !ensureRemovalOfParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: text) else { + return + } + // Emoji Fix: // Fallback to the default font, whenever the Active Font's Family doesn't match with the Default Font's family. // We do this twice (before and after inserting text), in order to properly handle two scenarios: @@ -304,7 +308,7 @@ open class TextView: UITextView { super.insertText(text) - ensureRemovalOfSingleLineParagraphAttributes(afterInserting: text) + ensureRemovalOfSingleLineParagraphAttributesAfterPressingEnter(input: text) restoreDefaultFontIfNeeded() @@ -1111,7 +1115,7 @@ open class TextView: UITextView { } -// MARK: - Paragraph Formatter Rendering Workarounds +// MARK: - Single line attributes removal private extension TextView { @@ -1122,8 +1126,8 @@ private extension TextView { /// - Parameters: /// - text: the text that was just inserted into the TextView. /// - func ensureRemovalOfSingleLineParagraphAttributes(afterInserting text: String) { - guard mustRemoveSingleLineParagraphAttributes(afterInserting: text) else { + func ensureRemovalOfSingleLineParagraphAttributesAfterPressingEnter(input: String) { + guard mustRemoveSingleLineParagraphAttributesAfterPressingEnter(input: input) else { return } @@ -1138,8 +1142,8 @@ private extension TextView { /// /// - Returns: `true` if we should remove paragraph attributes, otherwise it returns `false`. /// - func mustRemoveSingleLineParagraphAttributes(afterInserting text: String) -> Bool { - return text.isEndOfLine() + func mustRemoveSingleLineParagraphAttributesAfterPressingEnter(input: String) -> Bool { + return input.isEndOfLine() } @@ -1163,13 +1167,38 @@ private extension TextView { let applicationRange = formatter.applicationRange(for: selectedRange, in: textStorage) formatter.removeAttributes(from: textStorage, at: applicationRange) } + } + + // MARK: - Remove paragraph styles when pressing enter in an empty paragraph + + /// Removes paragraph attributes after a selection change. The logic that defines if the + /// attributes must be removed is located in + /// `mustRemoveSingleLineParagraphAttributesAfterSelectionChange()`. + /// + /// - Parameters: + /// - input: the user's input. This method must be called before the input is processed. + /// + func ensureRemovalOfParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: String) -> Bool { + guard mustRemoveParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: input) else { + return false + } + removeParagraphAttributes(at: selectedRange) + + return true } -} -// MARK: - Paragraph Formatter Rendering Workarounds + /// Analyzes whether paragraph attributes should be removed from the specified + /// location, or not, after the selection range is changed. + /// + /// - Returns: `true` if we should remove paragraph attributes, otherwise it returns `false`. + /// + func mustRemoveParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: String) -> Bool { + return input.isEndOfLine() + && storage.string.isEmptyParagraph(at: selectedRange.location) + } -private extension TextView { + // MARK: - WORKAROUND: removing styles at EOF due to selection change /// Removes paragraph attributes after a selection change. The logic that defines if the /// attributes must be removed is located in @@ -1205,17 +1234,11 @@ private extension TextView { TextListFormatter(style: .unordered) ] - guard range.location >= storage.length else { - for formatter in formatters where formatter.present(in: textStorage, at: range.location) { - formatter.removeAttributes(from: textStorage, at: range) - return - } - - return - } - for formatter in formatters where formatter.present(in: super.typingAttributes) { typingAttributes = formatter.remove(from: typingAttributes) + + let applicationRange = formatter.applicationRange(for: selectedRange, in: textStorage) + formatter.removeAttributes(from: textStorage, at: applicationRange) } } } From 34b89fc7b1e79d3196860fcd753728d2bde018de Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 08:28:00 -0300 Subject: [PATCH 14/25] Fixes some issues with lists. --- Aztec/Classes/TextKit/TextView.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index fc342b886..f63db047c 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -291,6 +291,12 @@ open class TextView: UITextView { return } + /// 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). + /// + ensureInsertionOfEndOfLine(beforeInserting: text) + // Emoji Fix: // Fallback to the default font, whenever the Active Font's Family doesn't match with the Default Font's family. // We do this twice (before and after inserting text), in order to properly handle two scenarios: From 2b2b689c322b7f11cb0b1535f9506c166fb986f5 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 08:51:54 -0300 Subject: [PATCH 15/25] Fixes some issues with lists. --- Aztec/Classes/TextKit/TextView.swift | 72 +++++++++++++++++++--------- 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index f63db047c..69b5643d7 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -287,7 +287,7 @@ open class TextView: UITextView { open override func insertText(_ text: String) { - guard !ensureRemovalOfParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: text) else { + guard !ensureRemovalOfTextListAttributesWhenPressingEnterInAnEmptyParagraph(input: text) else { return } @@ -334,11 +334,9 @@ open class TextView: UITextView { super.deleteBackward() - if storage.string.isEmpty { - return - } + //refreshStylesAfterDeletion(of: deletedString, at: deletionRange) + ensureRemovalOfParagraphAttributesWhenPressingBackspaceAndEmptyingTheDocument() - refreshStylesAfterDeletion(of: deletedString, at: deletionRange) ensureCursorRedraw(afterEditing: deletedString.string) delegate?.textViewDidChange?(self) } @@ -795,15 +793,6 @@ open class TextView: UITextView { typingAttributes.removeValue(forKey: NSLinkAttributeName) } - private let formattersThatBreakAfterEnter: [AttributeFormatter] = [ - HeaderFormatter(headerLevel:.h1), - HeaderFormatter(headerLevel:.h2), - HeaderFormatter(headerLevel:.h3), - HeaderFormatter(headerLevel:.h4), - HeaderFormatter(headerLevel:.h5), - HeaderFormatter(headerLevel:.h6), - ] - /// Force the SDK to Redraw the cursor, asynchronously, if the edited text (inserted / deleted) requires it. /// This method was meant as a workaround for Issue #144. @@ -1167,7 +1156,7 @@ private extension TextView { HeaderFormatter(headerLevel: .h6, placeholderAttributes: [:]) ] - for formatter in formatters where formatter.present(in: super.typingAttributes) { + for formatter in formatters where formatter.present(in: typingAttributes) { typingAttributes = formatter.remove(from: typingAttributes) let applicationRange = formatter.applicationRange(for: selectedRange, in: textStorage) @@ -1177,15 +1166,13 @@ private extension TextView { // MARK: - Remove paragraph styles when pressing enter in an empty paragraph - /// Removes paragraph attributes after a selection change. The logic that defines if the - /// attributes must be removed is located in - /// `mustRemoveSingleLineParagraphAttributesAfterSelectionChange()`. + /// Removes text list attributes after pressing enter in an empty paragraph. /// /// - Parameters: /// - input: the user's input. This method must be called before the input is processed. /// - func ensureRemovalOfParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: String) -> Bool { - guard mustRemoveParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: input) else { + func ensureRemovalOfTextListAttributesWhenPressingEnterInAnEmptyParagraph(input: String) -> Bool { + guard mustRemoveTextListAttributesWhenPressingEnterInAnEmptyParagraph(input: input) else { return false } @@ -1194,14 +1181,53 @@ private extension TextView { return true } - /// Analyzes whether paragraph attributes should be removed from the specified - /// location, or not, after the selection range is changed. + /// Analyzes whether text list attributes should be removed after pressing enter in an empty + /// paragraph. /// /// - Returns: `true` if we should remove paragraph attributes, otherwise it returns `false`. /// - func mustRemoveParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: String) -> Bool { + func mustRemoveTextListAttributesWhenPressingEnterInAnEmptyParagraph(input: String) -> Bool { return input.isEndOfLine() && storage.string.isEmptyParagraph(at: selectedRange.location) + && TextListFormatter.listsOfAnyKindPresent(in: typingAttributes) + } + + /// Removes the Paragraph Attributes [Blockquote, Pre, Lists] at the specified range. If the range + /// is beyond the storage's contents, the typingAttributes will be modified + /// + func removeTextListAttributes(at range: NSRange) { + let formatters: [AttributeFormatter] = [ + TextListFormatter(style: .ordered), + TextListFormatter(style: .unordered) + ] + + for formatter in formatters where formatter.present(in: super.typingAttributes) { + typingAttributes = formatter.remove(from: typingAttributes) + + let applicationRange = formatter.applicationRange(for: selectedRange, in: textStorage) + formatter.removeAttributes(from: textStorage, at: applicationRange) + } + } + + // MARK: - Remove paragraph styles when pressing backspace and removing the last character + + /// Removes paragraph attributes after pressing backspace, if the resulting document is empty. + /// + func ensureRemovalOfParagraphAttributesWhenPressingBackspaceAndEmptyingTheDocument() { + guard mustRemoveParagraphAttributesWhenPressingBackspaceAndEmptyingTheDocument() else { + return + } + + removeParagraphAttributes(at: selectedRange) + } + + /// Analyzes whether paragraph attributes should be removed from the specified + /// location, or not, after pressing backspace. + /// + /// - Returns: `true` if we should remove paragraph attributes, otherwise it returns `false`. + /// + func mustRemoveParagraphAttributesWhenPressingBackspaceAndEmptyingTheDocument() -> Bool { + return storage.length == 0 } // MARK: - WORKAROUND: removing styles at EOF due to selection change From c5c9084f6779b2f209203a66af85566880382a09 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 09:18:57 -0300 Subject: [PATCH 16/25] Fixes several unit tests. --- Aztec/Classes/TextKit/TextView.swift | 18 +++++++---- AztecTests/TextViewTests.swift | 48 +++++++++++++--------------- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 69b5643d7..8a351162a 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -287,7 +287,7 @@ open class TextView: UITextView { open override func insertText(_ text: String) { - guard !ensureRemovalOfTextListAttributesWhenPressingEnterInAnEmptyParagraph(input: text) else { + guard !ensureRemovalOfParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: text) else { return } @@ -1166,13 +1166,13 @@ private extension TextView { // MARK: - Remove paragraph styles when pressing enter in an empty paragraph - /// Removes text list attributes after pressing enter in an empty paragraph. + /// Removes paragraph attributes after pressing enter in an empty paragraph. /// /// - Parameters: /// - input: the user's input. This method must be called before the input is processed. /// - func ensureRemovalOfTextListAttributesWhenPressingEnterInAnEmptyParagraph(input: String) -> Bool { - guard mustRemoveTextListAttributesWhenPressingEnterInAnEmptyParagraph(input: input) else { + func ensureRemovalOfParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: String) -> Bool { + guard mustRemoveParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: input) else { return false } @@ -1181,15 +1181,17 @@ private extension TextView { return true } - /// Analyzes whether text list attributes should be removed after pressing enter in an empty + /// Analyzes whether paragraph attributes should be removed after pressing enter in an empty /// paragraph. /// /// - Returns: `true` if we should remove paragraph attributes, otherwise it returns `false`. /// - func mustRemoveTextListAttributesWhenPressingEnterInAnEmptyParagraph(input: String) -> Bool { + func mustRemoveParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: String) -> Bool { return input.isEndOfLine() && storage.string.isEmptyParagraph(at: selectedRange.location) - && TextListFormatter.listsOfAnyKindPresent(in: typingAttributes) + && (BlockquoteFormatter().present(in: typingAttributes) + || TextListFormatter.listsOfAnyKindPresent(in: typingAttributes) + || PreFormatter().present(in: typingAttributes)) } /// Removes the Paragraph Attributes [Blockquote, Pre, Lists] at the specified range. If the range @@ -1197,6 +1199,8 @@ private extension TextView { /// func removeTextListAttributes(at range: NSRange) { let formatters: [AttributeFormatter] = [ + BlockquoteFormatter(), + PreFormatter(), TextListFormatter(style: .ordered), TextListFormatter(style: .unordered) ] diff --git a/AztecTests/TextViewTests.swift b/AztecTests/TextViewTests.swift index 27696c411..dd654d382 100644 --- a/AztecTests/TextViewTests.swift +++ b/AztecTests/TextViewTests.swift @@ -764,7 +764,7 @@ class TextViewTests: XCTestCase { // Toggle List + Move the selection to the EOD textView.toggleOrderedList(range: .zero) - textView.selectedRange = textView.text.endOfStringNSRange() + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) // Insert Newline var expectedLength = textView.text.characters.count @@ -824,7 +824,7 @@ class TextViewTests: XCTestCase { let textView = createTextView(withHTML: "") textView.toggleOrderedList(range: .zero) - textView.selectedRange = textView.text.endOfStringNSRange() + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) XCTAssertFalse(TextListFormatter.listsOfAnyKindPresent(in: textView.typingAttributes)) } @@ -864,10 +864,11 @@ class TextViewTests: XCTestCase { let textView = createTextView(withHTML: "") textView.toggleUnorderedList(range: .zero) - XCTAssertEqual(textView.text, String(.newline)) + XCTAssert(textView.text.isEndOfLine()) } - /// Verifies that toggling an Unordered List, when editing the end of a non empty document, inserts a Newline. + /// Verifies that toggling an Unordered List, when editing the end of a non empty line should + /// never insert a newline, but that a newline is inserted for an empty line. /// /// Input: /// - "Something Here" @@ -879,12 +880,11 @@ class TextViewTests: XCTestCase { func testTogglingUnorderedListsOnNonEmptyDocumentsWhenSelectedRangeIsAtTheEndOfDocumentWillInsertNewline() { let textView = createTextView(withHTML: Constants.sampleText0) - textView.selectedRange = textView.text.endOfStringNSRange() + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) textView.toggleUnorderedList(range: .zero) - XCTAssertEqual(textView.text, Constants.sampleText0 + String(.newline)) + XCTAssertEqual(textView.text, Constants.sampleText0) - textView.selectedRange = textView.text.endOfStringNSRange() - textView.deleteBackward() + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) textView.insertText(Constants.sampleText1) textView.insertText(String(.newline)) @@ -902,7 +902,7 @@ class TextViewTests: XCTestCase { let textView = createTextView(withHTML: "") textView.toggleOrderedList(range: .zero) - XCTAssertEqual(textView.text, String(.newline)) + XCTAssert(textView.text.isEndOfLine()) } /// Verifies that toggling an Ordered List, when editing the end of a non empty document, inserts a Newline. @@ -917,12 +917,11 @@ class TextViewTests: XCTestCase { func testTogglingOrderedListsOnNonEmptyDocumentsWhenSelectedRangeIsAtTheEndOfDocumentWillInsertNewline() { let textView = createTextView(withHTML: Constants.sampleText0) - textView.selectedRange = textView.text.endOfStringNSRange() + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) textView.toggleOrderedList(range: .zero) - XCTAssertEqual(textView.text, Constants.sampleText0 + String(.newline)) + XCTAssertEqual(textView.text, Constants.sampleText0) - textView.selectedRange = textView.text.endOfStringNSRange() - textView.deleteBackward() + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) textView.insertText(Constants.sampleText1) textView.insertText(String(.newline)) @@ -989,7 +988,7 @@ class TextViewTests: XCTestCase { let textView = createTextView(withHTML: "") textView.toggleBlockquote(range: .zero) - textView.selectedRange = textView.text.endOfStringNSRange() + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) var expectedLength = textView.text.characters.count textView.insertText(newline) @@ -1046,7 +1045,7 @@ class TextViewTests: XCTestCase { let textView = createTextView(withHTML: "") textView.toggleBlockquote(range: .zero) - textView.selectedRange = textView.text.endOfStringNSRange() + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) XCTAssertFalse(BlockquoteFormatter().present(in: textView.typingAttributes)) } @@ -1086,7 +1085,7 @@ class TextViewTests: XCTestCase { let textView = createTextView(withHTML: "") textView.toggleBlockquote(range: .zero) - XCTAssertEqual(textView.text, String(.newline)) + XCTAssertEqual(textView.text, String(.paragraphSeparator)) } /// Verifies that toggling a Blockquote, when editing the end of a non empty document, inserts a Newline. @@ -1104,16 +1103,15 @@ class TextViewTests: XCTestCase { func testTogglingBlockquoteOnNonEmptyDocumentsWhenSelectedRangeIsAtTheEndOfDocumentWillInsertNewline() { let textView = createTextView(withHTML: Constants.sampleText0) - textView.selectedRange = textView.text.endOfStringNSRange() + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) textView.toggleBlockquote(range: .zero) - XCTAssertEqual(textView.text, Constants.sampleText0 + String(.newline)) + XCTAssertEqual(textView.text, Constants.sampleText0) - textView.selectedRange = textView.text.endOfStringNSRange() - textView.deleteBackward() + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) textView.insertText(Constants.sampleText1) textView.insertText(String(.newline)) - - XCTAssertEqual(textView.text, Constants.sampleText0 + Constants.sampleText1 + String(.newline) + String(.newline)) + + XCTAssertEqual(textView.text, Constants.sampleText0 + Constants.sampleText1 + String(.paragraphSeparator) + String(.paragraphSeparator)) } @@ -1176,7 +1174,7 @@ class TextViewTests: XCTestCase { let textView = createTextView(withHTML: "") textView.togglePre(range: .zero) - textView.selectedRange = textView.text.endOfStringNSRange() + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) var expectedLength = textView.text.characters.count textView.insertText(newline) @@ -1233,7 +1231,7 @@ class TextViewTests: XCTestCase { let textView = createTextView(withHTML: "") textView.togglePre(range: .zero) - textView.selectedRange = textView.text.endOfStringNSRange() + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) XCTAssertFalse(PreFormatter().present(in: textView.typingAttributes)) } @@ -1273,7 +1271,7 @@ class TextViewTests: XCTestCase { let textView = createTextView(withHTML: "") textView.togglePre(range: .zero) - XCTAssertEqual(textView.text, String(.newline)) + XCTAssertEqual(textView.text, String(.paragraphSeparator)) } /// Verifies that toggling a Pre, when editing the end of a non empty document, inserts a Newline. From 79de3da12d48d15908cd47f76ddcba54e799e2a7 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 09:49:50 -0300 Subject: [PATCH 17/25] Fixes some issues with the paragraph style. --- Aztec.xcodeproj/project.pbxproj | 10 +++- .../HTMLNodeToNSAttributedString.swift | 1 + .../Formatters/HTMLParagraphFormatter.swift | 57 +++++++++++++++++++ .../GUI/FormatBar/FormattingIdentifier.swift | 1 + Aztec/Classes/Libxml2/DOMString.swift | 34 +++++++++-- Aztec/Classes/TextKit/HTMLParagraph.swift | 19 +++++++ Aztec/Classes/TextKit/ParagraphStyle.swift | 4 +- Aztec/Classes/TextKit/TextStorage.swift | 21 +++++++ Aztec/Classes/TextKit/TextView.swift | 1 + Example/Example/EditorDemoController.swift | 8 +++ 10 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 Aztec/Classes/Formatters/HTMLParagraphFormatter.swift create mode 100644 Aztec/Classes/TextKit/HTMLParagraph.swift diff --git a/Aztec.xcodeproj/project.pbxproj b/Aztec.xcodeproj/project.pbxproj index 535155de3..83f303396 100644 --- a/Aztec.xcodeproj/project.pbxproj +++ b/Aztec.xcodeproj/project.pbxproj @@ -90,6 +90,8 @@ F19587CF1EA7ECEE0078DD9C /* String+VisualRangeMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = F19587CE1EA7ECEE0078DD9C /* String+VisualRangeMapping.swift */; }; F19587D11EA7EEEE0078DD9C /* StringVisualRangeMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F19587D01EA7EEEE0078DD9C /* StringVisualRangeMappingTests.swift */; }; F1A218151E02D5B3000AF5EB /* UndoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A218141E02D5B3000AF5EB /* UndoManager.swift */; }; + F1ABC1B11EAE279D002E52FE /* HTMLParagraphFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABC1B01EAE279D002E52FE /* HTMLParagraphFormatter.swift */; }; + F1ABC1B31EAE2816002E52FE /* HTMLParagraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABC1B21EAE2816002E52FE /* HTMLParagraph.swift */; }; F1C05B991E37F99D007510EA /* Character+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C05B981E37F99D007510EA /* Character+Name.swift */; }; F1C05B9D1E37FA77007510EA /* String+CharacterName.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C05B9C1E37FA77007510EA /* String+CharacterName.swift */; }; F1C05B9F1E37FD2F007510EA /* NSAttributedString+CharacterName.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C05B9E1E37FD2F007510EA /* NSAttributedString+CharacterName.swift */; }; @@ -238,6 +240,8 @@ F19587CE1EA7ECEE0078DD9C /* String+VisualRangeMapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+VisualRangeMapping.swift"; sourceTree = ""; }; F19587D01EA7EEEE0078DD9C /* StringVisualRangeMappingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringVisualRangeMappingTests.swift; sourceTree = ""; }; F1A218141E02D5B3000AF5EB /* UndoManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UndoManager.swift; sourceTree = ""; }; + F1ABC1B01EAE279D002E52FE /* HTMLParagraphFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLParagraphFormatter.swift; sourceTree = ""; }; + F1ABC1B21EAE2816002E52FE /* HTMLParagraph.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLParagraph.swift; sourceTree = ""; }; F1C05B981E37F99D007510EA /* Character+Name.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Character+Name.swift"; sourceTree = ""; }; F1C05B9C1E37FA77007510EA /* String+CharacterName.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+CharacterName.swift"; sourceTree = ""; }; F1C05B9E1E37FD2F007510EA /* NSAttributedString+CharacterName.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+CharacterName.swift"; sourceTree = ""; }; @@ -490,9 +494,11 @@ 599F252E1D8BC9A1002871D6 /* TextKit */ = { isa = PBXGroup; children = ( + FF7C89A71E3A2B7C000472A8 /* Blockquote.swift */, B5AF89311E93CFC80051EFDB /* RenderableAttachmentDelegate.swift */, B572AC271E817CFE008948C2 /* CommentAttachment.swift */, B57D1C3C1E92C38000EA4B16 /* HTMLAttachment.swift */, + F1ABC1B21EAE2816002E52FE /* HTMLParagraph.swift */, FF7A1C501E5651EA00C4C7C8 /* LineAttachment.swift */, 599F25301D8BC9A1002871D6 /* MediaAttachment.swift */, FF4E265F1EA8DF1E005E8E42 /* ImageAttachment.swift */, @@ -502,7 +508,6 @@ 599F25321D8BC9A1002871D6 /* TextView.swift */, E109B51B1DC33F2C0099605E /* LayoutManager.swift */, FFA61E881DF18F3D00B71BF6 /* ParagraphStyle.swift */, - FF7C89A71E3A2B7C000472A8 /* Blockquote.swift */, ); path = TextKit; sourceTree = ""; @@ -583,6 +588,7 @@ FF7C89AB1E3A47F1000472A8 /* StandardAttributeFormatter.swift */, E1C163A41DB6056B00E66A83 /* BlockquoteFormatter.swift */, FF13CD4C1E5C8067000FF10E /* HeaderFormatter.swift */, + F1ABC1B01EAE279D002E52FE /* HTMLParagraphFormatter.swift */, FF8F85831E84162900C12BB4 /* PreFormatter.swift */, B5B86D3B1DA41A550083DB3F /* TextListFormatter.swift */, FFD436951E300EF700A0E26F /* FontFormatter.swift */, @@ -789,6 +795,7 @@ 599F25471D8BC9A1002871D6 /* HTMLConstants.swift in Sources */, 599F25371D8BC9A1002871D6 /* InAttributeConverter.swift in Sources */, 599F25531D8BC9A1002871D6 /* TextStorage.swift in Sources */, + F1ABC1B11EAE279D002E52FE /* HTMLParagraphFormatter.swift in Sources */, B572AC281E817CFE008948C2 /* CommentAttachment.swift in Sources */, F1FA0E861E6EF514009D98EE /* Node.swift in Sources */, 599F253C1D8BC9A1002871D6 /* OutHTMLAttributeConverter.swift in Sources */, @@ -845,6 +852,7 @@ 599F25391D8BC9A1002871D6 /* InHTMLConverter.swift in Sources */, F10BE6181EA7ADA6002E4625 /* NSMutableAttributedString+ReplaceOcurrences.swift in Sources */, F1A218151E02D5B3000AF5EB /* UndoManager.swift in Sources */, + F1ABC1B31EAE2816002E52FE /* HTMLParagraph.swift in Sources */, FFA61EC21DF6C1C900B71BF6 /* NSAttributedString+Archive.swift in Sources */, 599F253E1D8BC9A1002871D6 /* OutHTMLNodeConverter.swift in Sources */, E109B51C1DC33F2C0099605E /* LayoutManager.swift in Sources */, diff --git a/Aztec/Classes/Converters/HTMLNodeToNSAttributedString.swift b/Aztec/Classes/Converters/HTMLNodeToNSAttributedString.swift index 246f8723d..277f44273 100644 --- a/Aztec/Classes/Converters/HTMLNodeToNSAttributedString.swift +++ b/Aztec/Classes/Converters/HTMLNodeToNSAttributedString.swift @@ -275,6 +275,7 @@ class HMTLNodeToNSAttributedString: SafeConverter { .h4: HeaderFormatter(headerLevel: .h4), .h5: HeaderFormatter(headerLevel: .h5), .h6: HeaderFormatter(headerLevel: .h6), + .p: HTMLParagraphFormatter(), .pre: PreFormatter(), .video: VideoFormatter() ] diff --git a/Aztec/Classes/Formatters/HTMLParagraphFormatter.swift b/Aztec/Classes/Formatters/HTMLParagraphFormatter.swift new file mode 100644 index 000000000..9f4559e2f --- /dev/null +++ b/Aztec/Classes/Formatters/HTMLParagraphFormatter.swift @@ -0,0 +1,57 @@ +import Foundation +import UIKit + + +// MARK: - Blockquote Formatter +// +class HTMLParagraphFormatter: ParagraphAttributeFormatter { + + /// Attributes to be added by default + /// + let placeholderAttributes: [String : Any]? + + + /// Designated Initializer + /// + init(placeholderAttributes: [String : Any]? = nil) { + self.placeholderAttributes = placeholderAttributes + } + + + // MARK: - Overwriten Methods + + func apply(to attributes: [String : Any]) -> [String: Any] { + let newParagraphStyle = ParagraphStyle() + + if let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? NSParagraphStyle { + newParagraphStyle.setParagraphStyle(paragraphStyle) + } + + newParagraphStyle.htmlParagraph = HTMLParagraph() + + var resultingAttributes = attributes + resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle + return resultingAttributes + } + + func remove(from attributes:[String: Any]) -> [String: Any] { + guard let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle, + paragraphStyle.blockquote != nil + else { + return attributes + } + + let newParagraphStyle = ParagraphStyle() + newParagraphStyle.setParagraphStyle(paragraphStyle) + + var resultingAttributes = attributes + resultingAttributes[NSParagraphStyleAttributeName] = newParagraphStyle + return resultingAttributes + } + + func present(in attributes: [String : Any]) -> Bool { + let style = attributes[NSParagraphStyleAttributeName] as? ParagraphStyle + return style?.htmlParagraph != nil + } +} + diff --git a/Aztec/Classes/GUI/FormatBar/FormattingIdentifier.swift b/Aztec/Classes/GUI/FormatBar/FormattingIdentifier.swift index 160c29ea2..d37c9ceb8 100644 --- a/Aztec/Classes/GUI/FormatBar/FormattingIdentifier.swift +++ b/Aztec/Classes/GUI/FormatBar/FormattingIdentifier.swift @@ -21,4 +21,5 @@ public enum FormattingIdentifier: String { case header5 = "header5" case header6 = "header6" case horizontalruler = "horizontalruler" + case p = "p" } diff --git a/Aztec/Classes/Libxml2/DOMString.swift b/Aztec/Classes/Libxml2/DOMString.swift index 2f45f01af..05cc8b00d 100644 --- a/Aztec/Classes/Libxml2/DOMString.swift +++ b/Aztec/Classes/Libxml2/DOMString.swift @@ -367,6 +367,17 @@ extension Libxml2 { self?.removeHeaderSynchronously(headerLevel: headerLevel, spanning: range) } } + + /// Disables HTML paragraph from the specified range. + /// + /// - Parameters: + /// - range: the range to remove the style from. + /// + func removeHTMLParagraph(spanning range: NSRange) { + performAsyncUndoable { [weak self] in + self?.removeHTMLParagraphSynchronously(spanning: range) + } + } // MARK: - Remove Styles: Synchronously private func removeSynchronously(element: StandardElementType, at range: NSRange) { @@ -378,6 +389,10 @@ extension Libxml2 { domEditor.unwrap(range: range, fromElementsNamed: element.equivalentNames) } + private func removeBlockquoteSynchronously(spanning range: NSRange) { + domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.blockquote.equivalentNames) + } + private func removeBoldSynchronously(spanning range: NSRange) { domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.b.equivalentNames) } @@ -402,16 +417,16 @@ extension Libxml2 { domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.u.equivalentNames) } - private func removeBlockquoteSynchronously(spanning range: NSRange) { - domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.blockquote.equivalentNames) - } - private func removeHeaderSynchronously(headerLevel: Int, spanning range: NSRange) { guard let elementType = elementTypeForHeaderLevel(headerLevel) else { return } domEditor.unwrap(range: range, fromElementsNamed: elementType.equivalentNames) } + + private func removeHTMLParagraphSynchronously(spanning range: NSRange) { + domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.p.equivalentNames) + } // MARK: - Apply Styles @@ -505,6 +520,17 @@ extension Libxml2 { } } + /// Applies an HTML paragraph to the specified range. + /// + /// - Parameters: + /// - range: the range to apply the style to. + /// + func applyHTMLParagraph(spanning range: NSRange) { + performAsyncUndoable { [weak self] in + self?.applyElement(.p, spanning: range) + } + } + // MARK: - Header types private func elementTypeForHeaderLevel(_ headerLevel: Int) -> StandardElementType? { diff --git a/Aztec/Classes/TextKit/HTMLParagraph.swift b/Aztec/Classes/TextKit/HTMLParagraph.swift new file mode 100644 index 000000000..3dd926846 --- /dev/null +++ b/Aztec/Classes/TextKit/HTMLParagraph.swift @@ -0,0 +1,19 @@ +import Foundation + +class HTMLParagraph: NSObject, NSCoding { + public func encode(with aCoder: NSCoder) { + + } + + override public init() { + + } + + required public init?(coder aDecoder: NSCoder){ + + } + + static func ==(lhs: HTMLParagraph, rhs: HTMLParagraph) -> Bool { + return lhs === rhs + } +} diff --git a/Aztec/Classes/TextKit/ParagraphStyle.swift b/Aztec/Classes/TextKit/ParagraphStyle.swift index 18995705c..1a02dc670 100644 --- a/Aztec/Classes/TextKit/ParagraphStyle.swift +++ b/Aztec/Classes/TextKit/ParagraphStyle.swift @@ -7,8 +7,10 @@ open class ParagraphStyle: NSMutableParagraphStyle { case headerLevel } - var textList: TextList? var blockquote: Blockquote? + var htmlParagraph: HTMLParagraph? + var textList: TextList? + var headerLevel: Int = 0 override init() { diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index f70b454e9..020952313 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -479,6 +479,7 @@ open class TextStorage: NSTextStorage { processBlockquoteDifferences(in: domRange, betweenOriginal: sourceStyle?.blockquote, andNew: targetStyle?.blockquote) processListDifferences(in: domRange, betweenOriginal: sourceStyle?.textList, andNew: targetStyle?.textList) processHeaderDifferences(in: domRange, betweenOriginal: sourceStyle?.headerLevel, andNew: targetStyle?.headerLevel) + processHTMLParagraphDifferences(in: domRange, betweenOriginal: sourceStyle?.htmlParagraph, andNew: targetStyle?.htmlParagraph) case NSLinkAttributeName: let sourceStyle = sourceValue as? URL let targetStyle = targetValue as? URL @@ -597,6 +598,26 @@ open class TextStorage: NSTextStorage { dom.replace(range, withRawHTML: html) } + /// Processes differences in blockquote styles, and applies them to the DOM in the specified + /// range. + /// + /// - Parameters: + /// - range: the range in the DOM where the differences must be applied. + /// - originalStyle: the original Blockquote object if any. + /// - newStyle: the new Blockquote object. + /// + private func processHTMLParagraphDifferences(in range: NSRange, betweenOriginal originalStyle: HTMLParagraph?, andNew newStyle: HTMLParagraph?) { + + let addStyle = originalStyle == nil && newStyle != nil + let removeStyle = originalStyle != nil && newStyle == nil + + if addStyle { + dom.applyHTMLParagraph(spanning: range) + } else if removeStyle { + dom.removeHTMLParagraph(spanning: range) + } + } + /// Processes differences in the italic trait of two font objects, and applies them to the DOM /// in the specified range. diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 8a351162a..204df8451 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -422,6 +422,7 @@ open class TextView: UITextView { .header4: HeaderFormatter(headerLevel: .h4, placeholderAttributes: nil), .header5: HeaderFormatter(headerLevel: .h5, placeholderAttributes: nil), .header6: HeaderFormatter(headerLevel: .h6, placeholderAttributes: nil), + .p: HTMLParagraphFormatter() ] /// Get a list of format identifiers spanning the specified range as a String array. diff --git a/Example/Example/EditorDemoController.swift b/Example/Example/EditorDemoController.swift index a2c3feb81..d41e1ec0b 100644 --- a/Example/Example/EditorDemoController.swift +++ b/Example/Example/EditorDemoController.swift @@ -451,6 +451,8 @@ extension EditorDemoController : Aztec.FormatBarDelegate { insertMoreAttachment() case .horizontalruler: insertHorizontalRuler() + case .p: + break } updateFormatBar() @@ -1086,6 +1088,8 @@ extension FormattingIdentifier { return Gridicon.iconOfType(.heading) case .header6: return Gridicon.iconOfType(.heading) + case .p: + return Gridicon.iconOfType(.heading) } } @@ -1129,6 +1133,8 @@ extension FormattingIdentifier { return "formatToolbarToggleH5" case .header6: return "formatToolbarToggleH6" + case .p: + return "none" } } @@ -1172,6 +1178,8 @@ extension FormattingIdentifier { return NSLocalizedString("Header 5", comment: "Accessibility label for selecting h5 paragraph style button on the formatting toolbar.") case .header6: return NSLocalizedString("Header 6", comment: "Accessibility label for selecting h6 paragraph style button on the formatting toolbar.") + case .p: + return "" } } } From 20bf19bbc0af7ac44889a5c1df150aeff0d1c0cb Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 09:56:05 -0300 Subject: [PATCH 18/25] Fixes a unit test. --- AztecTests/HTML/ElementNodeTests.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/AztecTests/HTML/ElementNodeTests.swift b/AztecTests/HTML/ElementNodeTests.swift index b818437e9..130b610d3 100644 --- a/AztecTests/HTML/ElementNodeTests.swift +++ b/AztecTests/HTML/ElementNodeTests.swift @@ -1148,7 +1148,7 @@ class ElementNodeTests: XCTestCase { /// - New String: "link!" /// /// Expected results: - /// - Output: `

Click on this link!

` + /// - Output: `

Click on this

link!
` /// func testReplaceCharactersInRangeWithString3() { let text1 = "Click on this " @@ -1164,20 +1164,20 @@ class ElementNodeTests: XCTestCase { let finalText = "\(text1)\(text2)!" div.replaceCharacters(inRange: range, withString: newString) - - XCTAssertEqual(div.children.count, 1) + + XCTAssertEqual(div.text(), finalText) + XCTAssertEqual(div.children.count, 2) guard let newParagraph = div.children[0] as? ElementNode, newParagraph.name == "p" else { XCTFail("Expected a paragraph.") return } + + XCTAssertEqual(newParagraph.text(), text1) - XCTAssertEqual(newParagraph.children.count, 1) - - guard let textNode = newParagraph.children[0] as? TextNode, textNode.text() == finalText else { - - XCTFail("Expected a text node, with the full text.") - return + guard let textNode = div.children[1] as? TextNode, textNode.text() == newString else { + XCTFail("Expected a text node, with the full text.") + return } } From c03d123e8f86ee2d94fbea9546087e7ea181000e Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 10:00:14 -0300 Subject: [PATCH 19/25] Fixes a unit test. --- AztecTests/TextViewTests.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/AztecTests/TextViewTests.swift b/AztecTests/TextViewTests.swift index dd654d382..3cdbb6c88 100644 --- a/AztecTests/TextViewTests.swift +++ b/AztecTests/TextViewTests.swift @@ -1289,16 +1289,15 @@ class TextViewTests: XCTestCase { func testTogglingPreOnNonEmptyDocumentsWhenSelectedRangeIsAtTheEndOfDocumentWillInsertNewline() { let textView = createTextView(withHTML: Constants.sampleText0) - textView.selectedRange = textView.text.endOfStringNSRange() + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) textView.togglePre(range: .zero) - XCTAssertEqual(textView.text, Constants.sampleText0 + String(.newline)) + XCTAssertEqual(textView.text, Constants.sampleText0) - textView.selectedRange = textView.text.endOfStringNSRange() - textView.deleteBackward() + textView.selectedTextRange = textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument) textView.insertText(Constants.sampleText1) textView.insertText(String(.newline)) - XCTAssertEqual(textView.text, Constants.sampleText0 + Constants.sampleText1 + String(.newline) + String(.newline)) + XCTAssertEqual(textView.text, Constants.sampleText0 + Constants.sampleText1 + String(.paragraphSeparator) + String(.paragraphSeparator)) } } From a433695bc33a8f42924059f8902f308cbaba7b94 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 12:02:16 -0300 Subject: [PATCH 20/25] Adds initial support for lists. --- .../Classes/Extensions/String+EndOfLine.swift | 28 ++++++++++ .../Libxml2/DOM/Data/ElementNode.swift | 30 +++++----- Aztec/Classes/Libxml2/DOM/Data/TextNode.swift | 6 +- Aztec/Classes/Libxml2/DOMString.swift | 46 +++++++++++++++- .../Descriptors/ElementNodeDescriptor.swift | 10 +++- Aztec/Classes/TextKit/TextStorage.swift | 55 ++++++++++++++----- 6 files changed, 138 insertions(+), 37 deletions(-) diff --git a/Aztec/Classes/Extensions/String+EndOfLine.swift b/Aztec/Classes/Extensions/String+EndOfLine.swift index 381638dc3..63bf64846 100644 --- a/Aztec/Classes/Extensions/String+EndOfLine.swift +++ b/Aztec/Classes/Extensions/String+EndOfLine.swift @@ -69,6 +69,16 @@ extension String { return index == endIndex || substring(with: index ..< self.index(after: index)).isEndOfLine() } + func isEndOfLine(atUTF16Offset utf16Offset: Int) -> Bool { + let utf16Index = utf16.index(utf16.startIndex, offsetBy: utf16Offset) + + guard let index = utf16Index.samePosition(in: self) else { + fatalError("This should not be possible, review your logic.") + } + + return isEndOfLine(at: index) + } + /// Checks if the location passed is the beggining of a new line. /// /// - Parameters: @@ -98,4 +108,22 @@ extension String { return isStartOfNewLine(at: index) } + + /// Checks if the location passed is the beggining of a new line. + /// + /// - Parameters: + /// - offset: the receiver's offset to check + /// + /// - Returns: true if beggining of a new line false otherwise + /// + func isStartOfNewLine(atUTF16Offset utf16Offset: Int) -> Bool { + + let utf16Index = utf16.index(utf16.startIndex, offsetBy: utf16Offset) + + guard let index = utf16Index.samePosition(in: self) else { + fatalError("This should not be possible, review your logic.") + } + + return isStartOfNewLine(at: index) + } } diff --git a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift index 1bd58bfc1..7f78f2ddc 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift @@ -1520,12 +1520,6 @@ extension Libxml2 { var childrenToWrap = selectedChildren - if let childElementDescriptor = elementDescriptor.childDescriptor { - let newChild = wrap(children: selectedChildren, inElement: childElementDescriptor) - - childrenToWrap = [newChild] - } - guard selectedChildren.count > 0 else { assertionFailure("Avoid calling this method with no nodes.") return ElementNode(descriptor: elementDescriptor, editContext: editContext) @@ -1550,39 +1544,43 @@ extension Libxml2 { // First get the right sibling because if we do it the other round, lastNodeIndex will // be modified before we access it. // - let rightSibling = pushUp(siblingOrDescendantAtRightSideOf: lastNodeIndex, evaluatedBy: evaluation, bailIf: bailEvaluation) - let leftSibling = pushUp(siblingOrDescendantAtLeftSideOf: firstNodeIndex, evaluatedBy: evaluation, bailIf: bailEvaluation) + let rightSibling = elementDescriptor.canMergeRight ? pushUp(siblingOrDescendantAtRightSideOf: lastNodeIndex, evaluatedBy: evaluation, bailIf: bailEvaluation) : nil + let leftSibling = elementDescriptor.canMergeLeft ? pushUp(siblingOrDescendantAtLeftSideOf: firstNodeIndex, evaluatedBy: evaluation, bailIf: bailEvaluation) : nil - var result: ElementNode? + var wrapperElement: ElementNode? if let sibling = rightSibling { sibling.prepend(childrenToWrap) childrenToWrap = sibling.children - result = sibling + wrapperElement = sibling } if let sibling = leftSibling { sibling.append(childrenToWrap) childrenToWrap = sibling.children - result = sibling + wrapperElement = sibling if let rightSibling = rightSibling, rightSibling.children.count == 0 { rightSibling.removeFromParent() } } - if let result = result { - return result - } else { + let finalWrapper = wrapperElement ?? { () -> ElementNode in let newNode = ElementNode(descriptor: elementDescriptor, children: childrenToWrap, editContext: editContext) - + children.insert(newNode, at: firstNodeIndex) newNode.parent = self - + return newNode + }() + + if let childElementDescriptor = elementDescriptor.childDescriptor { + finalWrapper.wrap(children: selectedChildren, inElement: childElementDescriptor) } + + return finalWrapper } // MARK: - Editing behavior diff --git a/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift b/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift index 507c054be..f59b7a5b2 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/TextNode.swift @@ -222,7 +222,7 @@ extension Libxml2 { if components.count == 1 { append(sanitizedString: string) } else { - append(components: components, separatedBy: ElementNodeDescriptor(elementType: .br)) + append(components: components, separatedBy: ElementNodeDescriptor(elementType: .br, canMergeLeft: false, canMergeRight: false)) } } @@ -245,7 +245,7 @@ extension Libxml2 { if components.count == 1 { prepend(sanitizedString: string) } else { - prepend(components: components, separatedBy: ElementNodeDescriptor(elementType: .br)) + prepend(components: components, separatedBy: ElementNodeDescriptor(elementType: .br, canMergeLeft: false, canMergeRight: false)) } } @@ -279,7 +279,7 @@ extension Libxml2 { if components.count == 1 { replaceCharacters(inRange: range, withSanitizedString: string) } else { - replaceCharacters(inRange: range, withComponents: components, separatedBy: ElementNodeDescriptor(elementType: .br)) + replaceCharacters(inRange: range, withComponents: components, separatedBy: ElementNodeDescriptor(elementType: .br, canMergeLeft: false, canMergeRight: false)) } } diff --git a/Aztec/Classes/Libxml2/DOMString.swift b/Aztec/Classes/Libxml2/DOMString.swift index 05cc8b00d..29b3d44a8 100644 --- a/Aztec/Classes/Libxml2/DOMString.swift +++ b/Aztec/Classes/Libxml2/DOMString.swift @@ -341,6 +341,28 @@ extension Libxml2 { } } + /// Disables unordered list from the specified range. + /// + /// - Parameters: + /// - range: the range to remove the style from. + /// + func removeOrderedList(spanning range: NSRange) { + performAsyncUndoable { [weak self] in + self?.removeOrderedListSynchronously(spanning: range) + } + } + + /// Disables unordered list from the specified range. + /// + /// - Parameters: + /// - range: the range to remove the style from. + /// + func removeUnorderedList(spanning range: NSRange) { + performAsyncUndoable { [weak self] in + self?.removeUnorderedListSynchronously(spanning: range) + } + } + /// Disables blockquote from the specified range. /// /// - Parameters: @@ -417,6 +439,16 @@ extension Libxml2 { domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.u.equivalentNames) } + private func removeOrderedListSynchronously(spanning range: NSRange) { + domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.li.equivalentNames) + domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.ol.equivalentNames) + } + + private func removeUnorderedListSynchronously(spanning range: NSRange) { + domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.li.equivalentNames) + domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.ul.equivalentNames) + } + private func removeHeaderSynchronously(headerLevel: Int, spanning range: NSRange) { guard let elementType = elementTypeForHeaderLevel(headerLevel) else { return @@ -501,16 +533,26 @@ extension Libxml2 { } } - func applyOrderedList(spanning range: NSRange) { + func applyOrderedList(spanning range: NSRange, canMergeLeft: Bool, canMergeRight: Bool) { performAsyncUndoable { [weak self] in - let liDescriptor = ElementNodeDescriptor(elementType: .li) + let liDescriptor = ElementNodeDescriptor(elementType: .li, canMergeLeft: canMergeLeft, canMergeRight: canMergeRight) let olDescriptor = ElementNodeDescriptor(elementType: .ol, childDescriptor: liDescriptor) self?.applyElementDescriptor(olDescriptor, spanning: range) } } + func applyUnorderedList(spanning range: NSRange, canMergeLeft: Bool, canMergeRight: Bool) { + performAsyncUndoable { [weak self] in + + let liDescriptor = ElementNodeDescriptor(elementType: .li, canMergeLeft: canMergeLeft, canMergeRight: canMergeRight) + let ulDescriptor = ElementNodeDescriptor(elementType: .ul, childDescriptor: liDescriptor) + + self?.applyElementDescriptor(ulDescriptor, spanning: range) + } + } + func applyHeader(_ headerLevel:Int, spanning range:NSRange) { guard let elementType = elementTypeForHeaderLevel(headerLevel) else { return diff --git a/Aztec/Classes/Libxml2/Descriptors/ElementNodeDescriptor.swift b/Aztec/Classes/Libxml2/Descriptors/ElementNodeDescriptor.swift index e95e451b9..c8edb530c 100644 --- a/Aztec/Classes/Libxml2/Descriptors/ElementNodeDescriptor.swift +++ b/Aztec/Classes/Libxml2/Descriptors/ElementNodeDescriptor.swift @@ -11,6 +11,8 @@ extension Libxml2 { let attributes: [Attribute] let childDescriptor: ElementNodeDescriptor? let matchingNames: [String] + let canMergeLeft: Bool + let canMergeRight: Bool // MARK: - CustomReflectable @@ -20,15 +22,17 @@ extension Libxml2 { } } - init(name: String, childDescriptor: ElementNodeDescriptor? = nil, attributes: [Attribute] = [], matchingNames: [String] = []) { + init(name: String, childDescriptor: ElementNodeDescriptor? = nil, attributes: [Attribute] = [], matchingNames: [String] = [], canMergeLeft: Bool = true, canMergeRight: Bool = true) { self.attributes = attributes + self.canMergeLeft = canMergeLeft + self.canMergeRight = canMergeRight self.childDescriptor = childDescriptor self.matchingNames = matchingNames super.init(name: name) } - convenience init(elementType: StandardElementType, childDescriptor: ElementNodeDescriptor? = nil, attributes: [Attribute] = []) { - self.init(name: elementType.rawValue, childDescriptor: childDescriptor, attributes: attributes, matchingNames: elementType.equivalentNames) + convenience init(elementType: StandardElementType, childDescriptor: ElementNodeDescriptor? = nil, attributes: [Attribute] = [], canMergeLeft: Bool = true, canMergeRight: Bool = true) { + self.init(name: elementType.rawValue, childDescriptor: childDescriptor, attributes: attributes, matchingNames: elementType.equivalentNames, canMergeLeft: canMergeLeft, canMergeRight: canMergeRight) } // MARK: - Introspection diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index 020952313..92717153b 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -368,6 +368,10 @@ open class TextStorage: NSTextStorage { /// - range: the range to apply the styles to. /// private func applyStylesToDom(attributes: [String : Any], in range: NSRange) { + + let canMergeLeft = range.location > 0 ? !textStore.string.isStartOfNewLine(atUTF16Offset: range.location) : false + let canMergeRight = range.location + range.length < textStore.length - 1 ? !textStore.string.isEndOfLine(atUTF16Offset: range.location + range.length) : false + textStore.enumerateAttributeDifferences(in: range, against: attributes, do: { (subRange, key, sourceValue, targetValue) in let domRange = textStore.string.map(visualUTF16Range: subRange) @@ -376,7 +380,7 @@ open class TextStorage: NSTextStorage { return } - processAttributesDifference(in: domRange, key: key, sourceValue: sourceValue, targetValue: targetValue) + processAttributesDifference(in: domRange, key: key, sourceValue: sourceValue, targetValue: targetValue, canMergeLeft: canMergeLeft, canMergeRight: canMergeRight) }) } @@ -395,12 +399,15 @@ open class TextStorage: NSTextStorage { let originalAttributes = [String:Any]() let fullRange = NSRange(location: 0, length: attributedString.length) - let location = textStore.string.map(visualRange: NSRange(location: location, length: 0)).location + let domLocation = textStore.string.map(visualRange: NSRange(location: location, length: 0)).location + + let canMergeLeft = location > 0 ? !textStore.string.isStartOfNewLine(atUTF16Offset: location) : false + let canMergeRight = location < textStore.length - 1 ? !textStore.string.isEndOfLine(atUTF16Offset: location) : false attributedString.enumerateAttributeDifferences(in: fullRange, against: originalAttributes, do: { (subRange, key, sourceValue, targetValue) in // The source and target values are inverted since we're enumerating on the new string. - let domRange = NSRange(location: location + subRange.location, length: subRange.length) + let domRange = NSRange(location: domLocation + subRange.location, length: subRange.length) guard let swiftDomRange = dom.string().nsRange(fromUTF16NSRange: domRange) else { // This should not be possible, but if this ever happens in production it's better to lose @@ -410,7 +417,7 @@ open class TextStorage: NSTextStorage { return } - processAttributesDifference(in: swiftDomRange, key: key, sourceValue: targetValue, targetValue: sourceValue) + processAttributesDifference(in: swiftDomRange, key: key, sourceValue: targetValue, targetValue: sourceValue, canMergeLeft: canMergeLeft, canMergeRight: canMergeRight) }) } @@ -424,7 +431,14 @@ open class TextStorage: NSTextStorage { /// - sourceValue: the original value of the attribute /// - targetValue: the new value of the attribute /// - private func processAttributesDifference(in domRange: NSRange, key: String, sourceValue: Any?, targetValue: Any?) { + private func processAttributesDifference( + in domRange: NSRange, + key: String, + sourceValue: Any?, + targetValue: Any?, + canMergeLeft: Bool = true, + canMergeRight: Bool = true) { + let isCommentAttachment = sourceValue is CommentAttachment || targetValue is CommentAttachment let isHtmlAttachment = sourceValue is HTMLAttachment || targetValue is HTMLAttachment let isLineAttachment = sourceValue is LineAttachment || targetValue is LineAttachment @@ -477,7 +491,7 @@ open class TextStorage: NSTextStorage { let targetStyle = targetValue as? ParagraphStyle processBlockquoteDifferences(in: domRange, betweenOriginal: sourceStyle?.blockquote, andNew: targetStyle?.blockquote) - processListDifferences(in: domRange, betweenOriginal: sourceStyle?.textList, andNew: targetStyle?.textList) + processListDifferences(in: domRange, betweenOriginal: sourceStyle?.textList, andNew: targetStyle?.textList, canMergeLeft: canMergeLeft, canMergeRight: canMergeRight) processHeaderDifferences(in: domRange, betweenOriginal: sourceStyle?.headerLevel, andNew: targetStyle?.headerLevel) processHTMLParagraphDifferences(in: domRange, betweenOriginal: sourceStyle?.htmlParagraph, andNew: targetStyle?.htmlParagraph) case NSLinkAttributeName: @@ -721,15 +735,30 @@ open class TextStorage: NSTextStorage { /// - originalStyle: the original TextList object if any. /// - newStyle: the new Blockquote object. /// - private func processListDifferences(in range: NSRange, betweenOriginal originalStyle: TextList?, andNew newStyle: TextList?) { + private func processListDifferences(in range: NSRange, betweenOriginal originalStyle: TextList?, andNew newStyle: TextList?, canMergeLeft: Bool = false, canMergeRight: Bool = false) { - let addStyle = originalStyle == nil && newStyle != nil - let removeStyle = originalStyle != nil && newStyle == nil + let original = originalStyle?.style + let new = newStyle?.style - if addStyle { - dom.applyOrderedList(spanning: range) - } else if removeStyle { - dom.removeBlockquote(spanning: range) + guard original != new else { + return + } + + let removeOrdered = original == .ordered + let removeUnordered = original == .unordered + let addOrdered = new == .ordered + let addUnordered = new == .unordered + + if removeOrdered { + dom.removeOrderedList(spanning: range) + } else if removeUnordered { + dom.removeUnorderedList(spanning: range) + } + + if addOrdered { + dom.applyOrderedList(spanning: range, canMergeLeft: canMergeLeft, canMergeRight: canMergeRight) + } else if addUnordered { + dom.applyUnorderedList(spanning: range, canMergeLeft: canMergeLeft, canMergeRight: canMergeRight) } } From 4b96727b29d59d38c7cdd693b757979dad64a602 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 13:00:14 -0300 Subject: [PATCH 21/25] Fixes an issue with selection changes. --- Aztec/Classes/TextKit/TextStorage.swift | 4 ++-- Aztec/Classes/TextKit/TextView.swift | 18 ++++++++---------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index 92717153b..c89808469 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -351,11 +351,11 @@ open class TextStorage: NSTextStorage { dom.deleteBlockSeparator(at: targetDomRange.location) } - print("Pre: \(dom.getHTML())") + //print("Pre: \(dom.getHTML())") if domString.length > 0 { applyStylesToDom(from: domString, startingAt: range.location) } - print("Pos: \(dom.getHTML())") + //print("Pos: \(dom.getHTML())") } // MARK: - DOM: Applying Styles diff --git a/Aztec/Classes/TextKit/TextView.swift b/Aztec/Classes/TextKit/TextView.swift index 204df8451..d310533e2 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -151,20 +151,18 @@ open class TextView: UITextView { // MARK: - Overwritten Properties - /// This is currently triggered when the text selection changes, which makes it great for - /// updating typingAttributes for selection changes only (and not for text insertion). - /// + /// Overwrites Typing 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 /// - open override var selectedTextRange: UITextRange? { - set { - super.selectedTextRange = newValue - + override open var typingAttributes: [String: Any] { + get { ensureRemovalOfParagraphAttributesAfterSelectionChange() + return super.typingAttributes } - get { - return super.selectedTextRange + set { + super.typingAttributes = newValue } } @@ -1272,7 +1270,7 @@ private extension TextView { ] for formatter in formatters where formatter.present(in: super.typingAttributes) { - typingAttributes = formatter.remove(from: typingAttributes) + super.typingAttributes = formatter.remove(from: super.typingAttributes) let applicationRange = formatter.applicationRange(for: selectedRange, in: textStorage) formatter.removeAttributes(from: textStorage, at: applicationRange) From 71c21a039b3640d35eaa3939f7209cf1c5a6404a Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 13:15:50 -0300 Subject: [PATCH 22/25] Fixes a crashing bug. --- .../Libxml2/DOM/Data/ElementNode.swift | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift index 7f78f2ddc..b6f02ff0f 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift @@ -836,17 +836,8 @@ extension Libxml2 { /// - Parameters: /// - child: the node to append. /// - func append(_ child: Node) { - child.removeFromParent() - - if let lastChild = children.last as? TextNode, - let newChildTextNode = child as? TextNode { - - lastChild.append(newChildTextNode.text()) - } else { - children.append(child) - child.parent = self - } + func append(_ child: Node, tryToMergeWithSiblings: Bool = true) { + insert(child, at: children.count, tryToMergeWithSiblings: tryToMergeWithSiblings) } /// Appends a node to the list of children for this element. @@ -854,9 +845,9 @@ extension Libxml2 { /// - Parameters: /// - child: the node to append. /// - func append(_ children: [Node]) { + func append(_ children: [Node], tryToMergeWithSiblings: Bool = true) { for child in children { - append(child) + append(child, tryToMergeWithSiblings: tryToMergeWithSiblings) } } @@ -865,8 +856,8 @@ extension Libxml2 { /// - Parameters: /// - child: the node to prepend. /// - func prepend(_ child: Node) { - insert(child, at: 0) + func prepend(_ child: Node, tryToMergeWithSiblings: Bool) { + insert(child, at: 0, tryToMergeWithSiblings: tryToMergeWithSiblings) } /// Prepends children to the list of children for this element. @@ -874,9 +865,9 @@ extension Libxml2 { /// - Parameters: /// - children: the nodes to prepend. /// - func prepend(_ children: [Node]) { + func prepend(_ children: [Node], tryToMergeWithSiblings: Bool = true) { for index in stride(from: (children.count - 1), through: 0, by: -1) { - prepend(children[index]) + prepend(children[index], tryToMergeWithSiblings: tryToMergeWithSiblings) } } @@ -1550,14 +1541,14 @@ extension Libxml2 { var wrapperElement: ElementNode? if let sibling = rightSibling { - sibling.prepend(childrenToWrap) + sibling.prepend(childrenToWrap, tryToMergeWithSiblings: false) childrenToWrap = sibling.children wrapperElement = sibling } if let sibling = leftSibling { - sibling.append(childrenToWrap) + sibling.append(childrenToWrap, tryToMergeWithSiblings: false) childrenToWrap = sibling.children wrapperElement = sibling From b64f9d3121423e3299a74326b04eb15da8424ea9 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 19:03:09 -0300 Subject: [PATCH 23/25] Fixes a unit test. --- .../Libxml2/DOM/Data/ElementNode.swift | 2 +- Aztec/Classes/TextKit/ParagraphStyle.swift | 36 +++++++++++-------- Aztec/Classes/TextKit/TextStorage.swift | 4 +-- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift index b6f02ff0f..88ef00992 100644 --- a/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift +++ b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift @@ -856,7 +856,7 @@ extension Libxml2 { /// - Parameters: /// - child: the node to prepend. /// - func prepend(_ child: Node, tryToMergeWithSiblings: Bool) { + func prepend(_ child: Node, tryToMergeWithSiblings: Bool = true) { insert(child, at: 0, tryToMergeWithSiblings: tryToMergeWithSiblings) } diff --git a/Aztec/Classes/TextKit/ParagraphStyle.swift b/Aztec/Classes/TextKit/ParagraphStyle.swift index 1a02dc670..b20018ca9 100644 --- a/Aztec/Classes/TextKit/ParagraphStyle.swift +++ b/Aztec/Classes/TextKit/ParagraphStyle.swift @@ -1,7 +1,15 @@ import Foundation import UIKit -open class ParagraphStyle: NSMutableParagraphStyle { +open class ParagraphStyle: NSMutableParagraphStyle, CustomReflectable { + + // MARK: - CustomReflectable + + public var customMirror: Mirror { + get { + return Mirror(self, children: ["blockquote": blockquote as Any, "headerLevel": headerLevel, "htmlParagraph": htmlParagraph as Any, "textList": textList as Any]) + } + } private enum EncodingKeys: String { case headerLevel @@ -49,9 +57,10 @@ open class ParagraphStyle: NSMutableParagraphStyle { override open func setParagraphStyle(_ obj: NSParagraphStyle) { super.setParagraphStyle(obj) if let paragrahStyle = obj as? ParagraphStyle { - textList = paragrahStyle.textList blockquote = paragrahStyle.blockquote headerLevel = paragrahStyle.headerLevel + htmlParagraph = paragrahStyle.htmlParagraph + textList = paragrahStyle.textList } } @@ -77,15 +86,10 @@ open class ParagraphStyle: NSMutableParagraphStyle { return false } - if textList != otherParagraph.textList { - return false - } - - if blockquote != otherParagraph.blockquote { - return false - } - - if headerLevel != otherParagraph.headerLevel { + if blockquote != otherParagraph.blockquote + || headerLevel != otherParagraph.headerLevel + || htmlParagraph != otherParagraph.htmlParagraph + || textList != otherParagraph.textList { return false } @@ -100,9 +104,10 @@ open class ParagraphStyle: NSMutableParagraphStyle { let originalCopy = super.copy(with: zone) as! NSParagraphStyle let copy = ParagraphStyle() copy.setParagraphStyle(originalCopy) - copy.textList = textList copy.blockquote = blockquote copy.headerLevel = headerLevel + copy.htmlParagraph = htmlParagraph + copy.textList = textList return copy } @@ -111,9 +116,10 @@ open class ParagraphStyle: NSMutableParagraphStyle { let originalCopy = super.mutableCopy(with: zone) as! NSParagraphStyle let copy = ParagraphStyle() copy.setParagraphStyle(originalCopy) - copy.textList = textList copy.blockquote = blockquote copy.headerLevel = headerLevel + copy.htmlParagraph = htmlParagraph + copy.textList = textList return copy } @@ -135,7 +141,7 @@ open class ParagraphStyle: NSMutableParagraphStyle { return description } - open override var description:String { - return super.description + "\nTextList:\(String(describing: textList?.style))\nBlockquote:\(String(describing:blockquote))\nHeaderLevel:\(headerLevel)" + open override var description: String { + return super.description + " Blockquote: \(String(describing:blockquote)),\n HeaderLevel: \(headerLevel),\n HTMLParagraph: \(String(describing: htmlParagraph)),\n TextList: \(String(describing: textList?.style))" } } diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index c89808469..92717153b 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -351,11 +351,11 @@ open class TextStorage: NSTextStorage { dom.deleteBlockSeparator(at: targetDomRange.location) } - //print("Pre: \(dom.getHTML())") + print("Pre: \(dom.getHTML())") if domString.length > 0 { applyStylesToDom(from: domString, startingAt: range.location) } - //print("Pos: \(dom.getHTML())") + print("Pos: \(dom.getHTML())") } // MARK: - DOM: Applying Styles From 0e8168b8bd8e3d14cffbbbbb12a27c743e09ba66 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 24 Apr 2017 19:06:23 -0300 Subject: [PATCH 24/25] Fixes 2 unit tests. --- AztecTests/TextViewTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AztecTests/TextViewTests.swift b/AztecTests/TextViewTests.swift index 3cdbb6c88..90652e881 100644 --- a/AztecTests/TextViewTests.swift +++ b/AztecTests/TextViewTests.swift @@ -1111,7 +1111,7 @@ class TextViewTests: XCTestCase { textView.insertText(Constants.sampleText1) textView.insertText(String(.newline)) - XCTAssertEqual(textView.text, Constants.sampleText0 + Constants.sampleText1 + String(.paragraphSeparator) + String(.paragraphSeparator)) + XCTAssertEqual(textView.text, Constants.sampleText0 + Constants.sampleText1 + String(.newline) + String(.paragraphSeparator)) } @@ -1297,7 +1297,7 @@ class TextViewTests: XCTestCase { textView.insertText(Constants.sampleText1) textView.insertText(String(.newline)) - XCTAssertEqual(textView.text, Constants.sampleText0 + Constants.sampleText1 + String(.paragraphSeparator) + String(.paragraphSeparator)) + XCTAssertEqual(textView.text, Constants.sampleText0 + Constants.sampleText1 + String(.newline) + String(.paragraphSeparator)) } } From c44f5748f18e236fd6ec6224892f071b1d73a753 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Tue, 25 Apr 2017 11:41:30 -0300 Subject: [PATCH 25/25] Improves lists handling. --- .../Classes/Libxml2/DOM/Logic/DOMEditor.swift | 20 +++++++++++++++++ .../Libxml2/DOM/Logic/DOMInspector.swift | 22 +++++++++++++++++++ Aztec/Classes/Libxml2/DOMString.swift | 20 +++++++++++++++++ Aztec/Classes/TextKit/TextStorage.swift | 4 ++++ 4 files changed, 66 insertions(+) diff --git a/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift b/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift index a140a672e..bddc66222 100644 --- a/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift +++ b/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift @@ -288,6 +288,26 @@ extension Libxml2 { } } + // MARK: - Splitting Nodes + + func splitLowestBlockLevelElement(at location: Int) { + + let range = NSRange(location: location, length: 0) + let elementsAndIntersections = rootNode.lowestBlockLevelElements(intersectingRange: range) + + guard let elementAndIntersection = elementsAndIntersections.first else { + // If there's no block-level element to break, we simply add a line separator + // + rootNode.replaceCharacters(inRange: range, withString: String(.lineSeparator)) + return + } + + let elementToSplit = elementAndIntersection.element + let intersection = elementAndIntersection.intersection + + elementToSplit.split(atLocation: intersection.location) + } + // MARK: - Merging Nodes /// Merges the siblings found separated at the specified location. Since the DOM is a tree diff --git a/Aztec/Classes/Libxml2/DOM/Logic/DOMInspector.swift b/Aztec/Classes/Libxml2/DOM/Logic/DOMInspector.swift index ca17bf23e..a1e31e2b4 100644 --- a/Aztec/Classes/Libxml2/DOM/Logic/DOMInspector.swift +++ b/Aztec/Classes/Libxml2/DOM/Logic/DOMInspector.swift @@ -3,6 +3,26 @@ extension Libxml2 { /// class DOMInspector: DOMLogic { + // MARK: - Parent + + /// Call this method whenever you node the specified node MUST have a parent set. + /// This method will interrupt program execution if a parent isn't set. + /// + /// - Parameters: + /// - node: the node you want to get the parent of. + /// + /// - Returns: the parent element. + /// + func parent(of node: Node) -> ElementNode { + guard let parent = node.parent else { + fatalError("This method should only be called whenever you are sure a parent is set.") + } + + return parent + } + + // MARK: - Siblings + func rightSibling(of node: Node) -> Node? { guard let parent = node.parent else { assertionFailure("Shouldn't call this method in a node without a parent.") @@ -18,6 +38,8 @@ extension Libxml2 { return parent.children[nextIndex] } + // MARK: - Finding nodes + /// Finds a node ending at the specified location. /// //// - Parameters: diff --git a/Aztec/Classes/Libxml2/DOMString.swift b/Aztec/Classes/Libxml2/DOMString.swift index 29b3d44a8..d30d31803 100644 --- a/Aztec/Classes/Libxml2/DOMString.swift +++ b/Aztec/Classes/Libxml2/DOMString.swift @@ -141,6 +141,17 @@ extension Libxml2 { // MARK: - Editing + /// Adds a block-level elements separator at the specified location. + /// + /// - Parameters: + /// - location: the location of the block-level element separation we want to add. + /// + func addBlockSeparator(at location: Int) { + performAsyncUndoable { [weak self] in + self?.addBlockSeparatorSynchronously(at: location) + } + } + /// Deletes a block-level elements separator at the specified location. /// /// - Parameters: @@ -171,6 +182,15 @@ extension Libxml2 { // MARK: - Editing: Synchronously + /// Adds a block-level elements separator at the specified location. + /// + /// - Parameters: + /// - location: the location of the block-level element separation we want to add. + /// + private func addBlockSeparatorSynchronously(at location: Int) { + domEditor.splitLowestBlockLevelElement(at: location) + } + /// Deletes a block-level elements separator at the specified location. /// /// - Parameters: diff --git a/Aztec/Classes/TextKit/TextStorage.swift b/Aztec/Classes/TextKit/TextStorage.swift index 92717153b..625be47db 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -347,6 +347,10 @@ open class TextStorage: NSTextStorage { dom.replaceCharacters(inRange: targetDomRange, withString: domString.string) } + if attrString.string == String(.paragraphSeparator) { + dom.addBlockSeparator(at: targetDomRange.location) + } + if targetDomRange.length != swiftRange.length { dom.deleteBlockSeparator(at: targetDomRange.location) }