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/Extensions/String+EndOfLine.swift b/Aztec/Classes/Extensions/String+EndOfLine.swift index 1dddcb1fa..63bf64846 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. @@ -57,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: @@ -86,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/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/DOM/Data/ElementNode.swift b/Aztec/Classes/Libxml2/DOM/Data/ElementNode.swift index d1f962f95..88ef00992 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( @@ -685,19 +689,25 @@ 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 { 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. @@ -707,19 +717,25 @@ 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 { 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. @@ -820,10 +836,8 @@ extension Libxml2 { /// - Parameters: /// - child: the node to append. /// - func append(_ child: Node) { - child.removeFromParent() - 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. @@ -831,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) } } @@ -842,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 = true) { + insert(child, at: 0, tryToMergeWithSiblings: tryToMergeWithSiblings) } /// Prepends children to the list of children for this element. @@ -851,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) } } @@ -862,11 +876,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. @@ -1184,7 +1257,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) @@ -1236,111 +1309,21 @@ 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.") - } - - return - } + let nodesToInsert = nodesRepresenting(string) + let childrenBefore = splitChildren(before: location) + insert(nodesToInsert, at: childrenBefore.count) - 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, 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 @@ -1526,16 +1509,18 @@ extension Libxml2 { @discardableResult func wrap(children selectedChildren: [Node], inElement elementDescriptor: ElementNodeDescriptor) -> ElementNode { + var childrenToWrap = selectedChildren + 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.") } @@ -1550,40 +1535,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 childrenToWrap = selectedChildren - var result: ElementNode? + var wrapperElement: ElementNode? if let sibling = rightSibling { - sibling.prepend(childrenToWrap) + sibling.prepend(childrenToWrap, tryToMergeWithSiblings: false) childrenToWrap = sibling.children - result = sibling + wrapperElement = sibling } if let sibling = leftSibling { - sibling.append(childrenToWrap) + sibling.append(childrenToWrap, tryToMergeWithSiblings: false) 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 @@ -1672,6 +1660,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..f59b7a5b2 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() } } @@ -220,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)) } } @@ -243,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)) } } @@ -267,7 +269,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 @@ -277,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)) } } @@ -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) } } @@ -329,14 +331,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/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift b/Aztec/Classes/Libxml2/DOM/Logic/DOMEditor.swift index bb51d292a..bddc66222 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) { @@ -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) } @@ -295,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 aff8c1152..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: @@ -158,19 +169,28 @@ 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) } } } // 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: @@ -186,8 +206,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 @@ -341,6 +361,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: @@ -367,6 +409,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 +431,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,8 +459,14 @@ 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 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) { @@ -412,6 +475,10 @@ extension Libxml2 { } domEditor.unwrap(range: range, fromElementsNamed: elementType.equivalentNames) } + + private func removeHTMLParagraphSynchronously(spanning range: NSRange) { + domEditor.unwrap(range: range, fromElementsNamed: StandardElementType.p.equivalentNames) + } // MARK: - Apply Styles @@ -486,13 +553,25 @@ extension Libxml2 { } } - private func elementTypeForHeaderLevel(_ headerLevel: Int) -> StandardElementType? { - if headerLevel < 1 && headerLevel > DOMString.headerLevels.count { - return nil + func applyOrderedList(spanning range: NSRange, canMergeLeft: Bool, canMergeRight: Bool) { + performAsyncUndoable { [weak self] in + + let liDescriptor = ElementNodeDescriptor(elementType: .li, canMergeLeft: canMergeLeft, canMergeRight: canMergeRight) + let olDescriptor = ElementNodeDescriptor(elementType: .ol, childDescriptor: liDescriptor) + + self?.applyElementDescriptor(olDescriptor, spanning: range) } - return DOMString.headerLevels[headerLevel - 1] } + 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 { @@ -503,6 +582,25 @@ 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? { + if headerLevel < 1 && headerLevel > DOMString.headerLevels.count { + return nil + } + return DOMString.headerLevels[headerLevel - 1] + } // MARK: - Raw HTML @@ -532,7 +630,6 @@ extension Libxml2 { } } - // MARK: - Images /// Replaces the specified range with a given image. @@ -646,6 +743,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..c8edb530c 100644 --- a/Aztec/Classes/Libxml2/Descriptors/ElementNodeDescriptor.swift +++ b/Aztec/Classes/Libxml2/Descriptors/ElementNodeDescriptor.swift @@ -9,8 +9,11 @@ extension Libxml2 { /// class ElementNodeDescriptor: NodeDescriptor { let attributes: [Attribute] + let childDescriptor: ElementNodeDescriptor? let matchingNames: [String] - + let canMergeLeft: Bool + let canMergeRight: Bool + // MARK: - CustomReflectable public override var customMirror: Mirror { @@ -19,14 +22,17 @@ extension Libxml2 { } } - init(name: String, 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, attributes: [Attribute] = []) { - self.init(name: elementType.rawValue, 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/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..b20018ca9 100644 --- a/Aztec/Classes/TextKit/ParagraphStyle.swift +++ b/Aztec/Classes/TextKit/ParagraphStyle.swift @@ -1,14 +1,24 @@ 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 } - var textList: TextList? var blockquote: Blockquote? + var htmlParagraph: HTMLParagraph? + var textList: TextList? + var headerLevel: Int = 0 override init() { @@ -47,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 } } @@ -75,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 } @@ -98,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 } @@ -109,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 } @@ -133,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 2ec0fef51..625be47db 100644 --- a/Aztec/Classes/TextKit/TextStorage.swift +++ b/Aztec/Classes/TextKit/TextStorage.swift @@ -76,6 +76,13 @@ 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 public var undoManager: UndoManager? { @@ -250,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() { @@ -265,7 +273,7 @@ open class TextStorage: NSTextStorage { endEditing() } - + override open func replaceCharacters(in range: NSRange, with attrString: NSAttributedString) { let preprocessedString = preprocessAttributesForInsertion(attrString) @@ -286,7 +294,7 @@ open class TextStorage: NSTextStorage { override open func setAttributes(_ attrs: [String : Any]?, range: NSRange) { beginEditing() - if mustUpdateDOM(), let attributes = attrs { + if mustUpdateDOM() && allowFixingDOMAttributes && range.length > 0, let attributes = attrs { applyStylesToDom(attributes: attributes, in: range) } @@ -294,6 +302,21 @@ open class TextStorage: NSTextStorage { edited(.editedAttributes, range: range, changeInLength: 0) endEditing() + + 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 @@ -305,29 +328,38 @@ open class TextStorage: NSTextStorage { } let targetDomRange = string.map(visualUTF16Range: swiftRange) - let preferLeftNode = doesPreferLeftNode(atCaretPosition: swiftRange.location) - dom.replaceCharacters(inRange: targetDomRange, withString: str, preferLeftNode: preferLeftNode) + if targetDomRange.length > 0 || str.characters.count > 0 { + dom.replaceCharacters(inRange: targetDomRange, withString: str) + } } private func replaceCharactersInDOM(in range: NSRange, with attrString: NSAttributedString) { - guard let swiftRange = string.nsRange(fromUTF16NSRange: range) else { fatalError() } 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) + if targetDomRange.length > 0 || domString.length > 0 { + 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) } - applyStylesToDom(from: domString, startingAt: range.location) + print("Pre: \(dom.getHTML())") + if domString.length > 0 { + applyStylesToDom(from: domString, startingAt: range.location) + } + print("Pos: \(dom.getHTML())") } // MARK: - DOM: Applying Styles @@ -340,11 +372,19 @@ 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) - processAttributesDifference(in: domRange, key: key, sourceValue: sourceValue, targetValue: targetValue) + guard domRange.length > 0 else { + return + } + + processAttributesDifference(in: domRange, key: key, sourceValue: sourceValue, targetValue: targetValue, canMergeLeft: canMergeLeft, canMergeRight: canMergeRight) }) } @@ -360,15 +400,18 @@ 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 + 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 @@ -378,7 +421,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) }) } @@ -392,7 +435,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 @@ -443,9 +493,11 @@ 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, canMergeLeft: canMergeLeft, canMergeRight: canMergeRight) 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 @@ -564,6 +616,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. @@ -659,6 +731,41 @@ 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?, canMergeLeft: Bool = false, canMergeRight: Bool = false) { + + let original = originalStyle?.style + let new = newStyle?.style + + 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) + } + } + /// Processes differences in header styles, and applies them to the DOM in the specified /// range. /// @@ -702,69 +809,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 320ed09ab..d310533e2 100644 --- a/Aztec/Classes/TextKit/TextView.swift +++ b/Aztec/Classes/TextKit/TextView.swift @@ -149,6 +149,23 @@ open class TextView: UITextView { return textStorage as! TextStorage } + // 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. + /// For details, see: https://github.com/wordpress-mobile/AztecEditor-iOS/issues/414 + /// + override open var typingAttributes: [String: Any] { + get { + ensureRemovalOfParagraphAttributesAfterSelectionChange() + return super.typingAttributes + } + + set { + super.typingAttributes = newValue + } + } + // MARK: - Init & deinit public init(defaultFont: UIFont, defaultMissingImage: UIImage) { @@ -211,24 +228,6 @@ open class TextView: UITextView { addGestureRecognizer(attachmentGestureRecognizer) } - - // MARK: - Overwritten Properties - - /// Overwrites Typing Attributes: - /// This is the (only) valid hook we've found, in order to (selectively) remove the [Blockquote, List, Pre] attributes. - /// 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 - } - set { - super.typingAttributes = newValue - } - } - - // MARK: - Intercept copy paste operations open override func cut(_ sender: Any?) { @@ -286,26 +285,15 @@ open class TextView: UITextView { open override func insertText(_ text: String) { + guard !ensureRemovalOfParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: text) else { + 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). /// - ensureInsertionOfNewline(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 - } + 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. @@ -324,9 +312,9 @@ open class TextView: UITextView { super.insertText(text) - restoreDefaultFontIfNeeded() + ensureRemovalOfSingleLineParagraphAttributesAfterPressingEnter(input: text) - ensureRemovalOfSingleLineParagraphAttributes(insertedText: text, at: selectedRange) + restoreDefaultFontIfNeeded() ensureCursorRedraw(afterEditing: text) } @@ -344,11 +332,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) } @@ -434,6 +420,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. @@ -584,7 +571,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 +588,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 +601,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 +615,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 +700,27 @@ 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 selectedRangeForSwift.location == textStorage.length + && 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 +729,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,17 +753,17 @@ 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 - super.insertText(String(.newline)) + super.insertText(String(.paragraphSeparator)) selectedRange = previousRange typingAttributes = previousStyle @@ -797,40 +793,6 @@ open class TextView: UITextView { } - private let formattersThatBreakAfterEnter: [AttributeFormatter] = [ - HeaderFormatter(headerLevel:.h1), - HeaderFormatter(headerLevel:.h2), - HeaderFormatter(headerLevel:.h3), - HeaderFormatter(headerLevel:.h4), - 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. /// This method was meant as a workaround for Issue #144. /// @@ -1146,69 +1108,160 @@ open class TextView: UITextView { } } -// MARK: - Paragraph Formatters Rendering Workarounds -// + +// MARK: - Single line attributes removal + private extension TextView { - /// Removes the Paragraph Attributes whenever `mustRemoveParagraphAttributes(beforeInserting: at)` returns true. + /// 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! - /// - @discardableResult - func ensureRemovalOfParagraphAttributes(beforeInserting text: String? = nil, at selectedRange: NSRange) -> Bool { - guard mustRemoveParagraphAttributes(beforeInserting: text, at: selectedRange.location) else { - return false + func ensureRemovalOfSingleLineParagraphAttributesAfterPressingEnter(input: String) { + guard mustRemoveSingleLineParagraphAttributesAfterPressingEnter(input: input) else { + return } - return removeParagraphAttributes(at: selectedRange) + removeSingleLineParagraphAttributes(at: selectedRange) + } + + /// Analyzes whether paragraph attributes should be removed from the specified + /// location, or not, after the selection range is changed. + /// + /// - Parameters: + /// - text: the text that was just inserted into the TextView. + /// + /// - Returns: `true` if we should remove paragraph attributes, otherwise it returns `false`. + /// + func mustRemoveSingleLineParagraphAttributesAfterPressingEnter(input: String) -> Bool { + return input.isEndOfLine() } - /// Analyzes whether the Paragraph Attributes should be removed at a specified location, or not. - /// This is necessary in two different scenarios: + /// 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 /// - /// Scenario A: + 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: 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 enter in an empty paragraph + + /// Removes paragraph attributes after pressing enter in an empty paragraph. /// - /// A. We're at the end of the document - /// B. Below there's an empty line. - /// C. The user pressed Arrow Down + /// - Parameters: + /// - input: the user's input. This method must be called before the input is processed. /// - /// Why: We only want to carry over the `Paragraph Attribute` if a Newline is explicitly pressed. + func ensureRemovalOfParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: String) -> Bool { + guard mustRemoveParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: input) else { + return false + } + + removeParagraphAttributes(at: selectedRange) + + return true + } + + /// Analyzes whether paragraph attributes should be removed after pressing enter in an empty + /// paragraph. /// - /// Scenario B: + /// - Returns: `true` if we should remove paragraph attributes, otherwise it returns `false`. /// - /// 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 + func mustRemoveParagraphAttributesWhenPressingEnterInAnEmptyParagraph(input: String) -> Bool { + return input.isEndOfLine() + && storage.string.isEmptyParagraph(at: selectedRange.location) + && (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 + /// is beyond the storage's contents, the typingAttributes will be modified /// - /// Why: We wanna take care of removing [Lists, Pre, Blockquotes] if the user hits return on an empty line. + func removeTextListAttributes(at range: NSRange) { + let formatters: [AttributeFormatter] = [ + BlockquoteFormatter(), + PreFormatter(), + 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. /// - /// - Parameters: - /// - text: String that is about to be inserted. - /// - location: Selected Range's Location + /// - Returns: `true` if we should remove paragraph attributes, otherwise it returns `false`. /// - /// - Returns: true if we should remove the paragraph attributes. false otherwise! + func mustRemoveParagraphAttributesWhenPressingBackspaceAndEmptyingTheDocument() -> Bool { + return storage.length == 0 + } + + // 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 + /// `mustRemoveSingleLineParagraphAttributesAfterSelectionChange()`. /// - func mustRemoveParagraphAttributes(beforeInserting text: String? = nil, at location: Int) -> Bool { - guard text?.isEndOfLine() == true || location == storage.length else { - return false + func ensureRemovalOfParagraphAttributesAfterSelectionChange() { + guard mustRemoveParagraphAttributesAfterSelectionChange() else { + return } - return storage.string.isEmptyParagraph(at: location) + removeParagraphAttributes(at: selectedRange) + } + + /// 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 mustRemoveParagraphAttributesAfterSelectionChange() -> Bool { + return selectedRange.location == storage.length + && storage.string.isEmptyParagraph(at: selectedRange.location) } /// 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 removeParagraphAttributes(at range: NSRange) -> Bool { + func removeParagraphAttributes(at range: NSRange) { let formatters: [AttributeFormatter] = [ BlockquoteFormatter(), PreFormatter(placeholderAttributes: defaultAttributes), @@ -1216,21 +1269,12 @@ 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 true - } - - return false - } - for formatter in formatters where formatter.present(in: super.typingAttributes) { super.typingAttributes = formatter.remove(from: super.typingAttributes) - return true + + let applicationRange = formatter.applicationRange(for: selectedRange, in: textStorage) + formatter.removeAttributes(from: textStorage, at: applicationRange) } - - return false } } diff --git a/AztecTests/HTML/DOMEditorTests.swift b/AztecTests/HTML/DOMEditorTests.swift index 00af0c9b1..aea2a89b2 100644 --- a/AztecTests/HTML/DOMEditorTests.swift +++ b/AztecTests/HTML/DOMEditorTests.swift @@ -10,110 +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 paragraph = ElementNode(name: "p", attributes: [], children: [textNode]) - let rootNode = RootNode(children: [paragraph]) - - 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(paragraph.children.count, 2) - - guard let newBoldNode = paragraph.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 { - 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!
@@ -143,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) } @@ -292,14 +188,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 +210,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 +233,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/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..130b610d3 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,10 +1146,9 @@ 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!

` + /// - Output: `

Click on this

link!
` /// func testReplaceCharactersInRangeWithString3() { let text1 = "Click on this " @@ -1169,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 } } @@ -1214,7 +1209,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() diff --git a/AztecTests/TextViewTests.swift b/AztecTests/TextViewTests.swift index 822d7ec73..90652e881 100644 --- a/AztecTests/TextViewTests.swift +++ b/AztecTests/TextViewTests.swift @@ -602,7 +602,7 @@ class TextViewTests: 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() @@ -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(.newline) + 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. @@ -1291,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(.newline) + String(.paragraphSeparator)) } } 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 "" } } }